Zero-Downtime Database Migrations with Prisma

Photo by Jemimus on flickr
Expand-contract is a migration strategy where you add new schema structures alongside old ones, migrate data and application code over in small reversible steps, and only remove the old structures after nothing depends on them anymore. It avoids the downtime that comes from changing a column's shape in the same deploy that the application code expects the change to already be live.
Adding a plain nullable column is a metadata-only change and completes in milliseconds. Adding a NOT NULL constraint or a volatile default forces Postgres to validate or rewrite every existing row, and that rewrite holds an ACCESS EXCLUSIVE lock for its entire duration, blocking every other read and write to the table until it finishes.
Write a standalone script that walks the table in small ordered batches using the primary key as a cursor, committing each batch in its own short transaction rather than one giant UPDATE. Add a short pause between batches and point verification queries at a read replica so the backfill never competes with production traffic for locks or connections.
Not without restoring from a backup or point-in-time recovery snapshot. Dropping a column is the one step in the expand-contract pattern that is not reversible through a normal application rollback, which is why it should always be its own dedicated migration deployed separately from any code change, with a fresh backup taken immediately beforehand.
Run prisma migrate dev with the create-only flag to generate the migration.sql file without applying it. You can then open the file, remove constraints the CLI added automatically, split it into safer statements, or add custom SQL such as a RENAME COLUMN, before running prisma migrate dev again to apply the edited version.

Photo by Jemimus on flickr
The scariest deploy on any backend team is a schema migration. Adding a feature is low risk. Changing the shape of a table that production traffic is hitting right now is a different game entirely, because for a window of time, old application code and new application code both have to work against the same database, and one wrong ALTER statement can take an entire service down.
This post walks through the expand-contract pattern for Prisma and Postgres: how to add columns without locking the table, how to backfill millions of rows without starving your connection pool, how to run a safe dual-write window, and how to drop the old column later without anyone noticing. Every command here is something you can run today with Prisma Migrate.
A naive migration renames a column, changes its type, or adds a NOT NULL constraint in one step, then ships new application code that expects the change to already be live. The problem is deployment is never instant. There is always a window where old server instances are still running against the new schema, or new instances are running against the old one, especially in a rolling deploy behind a load balancer. If the migration and the code change are not decoupled, that window produces failed queries, 500s, or silent data corruption. Postgres compounds the risk: certain ALTER TABLE forms take an ACCESS EXCLUSIVE lock, and while a plain column addition is usually a fast metadata-only change, adding a column with a volatile default, a NOT NULL constraint that requires a full validation scan, or changing a column's type can force Postgres to rewrite the entire table and its indexes, blocking every other reader and writer until it finishes.
Never remove or repurpose something the running application still depends on in the same deploy that changes it: expand the schema by adding the new structure alongside the old one, migrate the data and the code over in small reversible steps, and only contract by removing the old structure once nothing reads it anymore. Below is the rollout timeline this post follows, using a fictional Invoice table that is migrating its amount field from integer cents to a proper Decimal column.
| Phase | What Happens | Old Code Still Works? |
|---|---|---|
| 1. Expand | Add the new nullable column via a fast, lock-safe migration | Yes |
| 2. Backfill | Populate the new column in small batches from existing data | Yes |
| 3. Dual-write | Deploy code that writes both columns; reads still use the old one | Yes |
| 4. Cutover | Deploy code that reads and writes only the new column | Yes, old data is untouched |
| 5. Contract | Drop the old column in a later, separate release | No rollback past this point |
The golden rule of the expand phase is: add the column, but do not attach a NOT NULL constraint or a volatile default in the same statement. Postgres can add a plain nullable column as a pure metadata change in milliseconds, because it does not need to touch a single existing row. The moment you add NOT NULL or a default value that is not a fixed constant, Postgres has to validate or rewrite every row, and that rewrite takes an exclusive lock for its entire duration. Prisma Migrate's default workflow runs migrations automatically, but for anything sensitive you should use the create-only flag so you can review and edit the generated SQL before it ever touches your database. That gives you the chance to strip out a NOT NULL constraint the CLI added, or split one migration into two separate, safer statements.
// schema.prisma — expand phase: add the new column alongside the old one
model Invoice {
id Int @id @default(autoincrement())
amountCents Int? // old: integer cents, still read by the current release
amount Decimal? @db.Decimal(12, 2) // new: proper decimal, nullable for now
status String
}
# Generate a draft migration without applying it
npx prisma migrate dev --create-only --name add-invoice-amount-decimal
# Edit the generated migration.sql before it runs:
# prisma/migrations/2026XXXXXXXXXX_add_invoice_amount_decimal/migration.sql
ALTER TABLE "Invoice" ADD COLUMN "amount" DECIMAL(12,2);
-- No DEFAULT, no NOT NULL yet: this is a metadata-only change,
-- so Postgres does not rewrite the table and the ACCESS EXCLUSIVE
-- lock it takes is held for milliseconds, not minutes.
# Now apply it
npx prisma migrate devAdding a NOT NULL constraint, a volatile default such as a timestamp function, or a generated column all force Postgres to rewrite the whole table and its indexes under an ACCESS EXCLUSIVE lock. On a large table that rewrite can take minutes, during which every read and write to that table blocks. Always add the column nullable first, backfill it, and only add NOT NULL once every row already has a value.
Once the new column exists, you need to populate it for every existing row without competing with live traffic for connections or locks. A single UPDATE statement across a multi-million-row table holds locks on every row it touches until the transaction commits, which can stall other queries for the whole run. The safer approach is a script that walks the table in ordered batches, using the primary key as a cursor, committing each batch in its own short transaction. This keeps each lock window small, lets you log progress so you can resume after a crash, and lets you throttle the pace if you see replication lag or connection pool pressure building. Run it as a one-off job, not inside a request handler, and point it at a read replica for verification queries so it never competes with production reads.
// scripts/backfill-invoice-amount.ts
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
const BATCH_SIZE = 500
async function backfill() {
let cursor: number | undefined = undefined
let migrated = 0
while (true) {
const rows = await prisma.invoice.findMany({
where: { amount: null },
take: BATCH_SIZE,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
orderBy: { id: 'asc' },
})
if (rows.length === 0) break
await prisma.$transaction(
rows.map((row) =>
prisma.invoice.update({
where: { id: row.id },
data: { amount: (row.amountCents ?? 0) / 100 },
})
)
)
cursor = rows[rows.length - 1].id
migrated += rows.length
console.log(`Backfilled ${migrated} rows...`)
// Small pause keeps replication lag and connection pool pressure low
await new Promise((r) => setTimeout(r, 50))
}
console.log(`Done. ${migrated} rows backfilled.`)
}
backfill().finally(() => prisma.$disconnect())Add a short pause between batches. A tight loop that backfills as fast as the database will allow can starve your connection pool and spike replication lag on read replicas, even though every individual transaction is small and fast.
Before you can trust the new column, deploy a release where application code writes to both the old and new columns on every insert and update, while reads still come from the old column. This is the safety net: if the backfill missed an edge case or the new column has a subtle bug, the old column is still the source of truth and nothing user-facing breaks. Keep this window as short as reasonably possible, but long enough to catch a full business cycle of writes, including any batch jobs or webhooks that write to the table outside the normal request path.
Once the reconciliation query has been clean for your chosen window, ship the cutover release: application code reads and writes only the new column, and the old column becomes dead weight rather than a dependency. This is still reversible, because the old column still exists with its last known values, so you can roll back the release without a data migration. Only after cutover has been stable in production do you contract: remove the old column in its own dedicated migration, deployed separately from any code change. Splitting it out matters because dropping a column is destructive and should never be bundled with unrelated schema or feature work.
The entire point of this pattern is that at every step except the very last one, you can roll back the application release without touching the database at all. That is what makes expand-contract genuinely zero-downtime instead of just faster-downtime.
In practice this becomes five small, boring, individually reviewable pull requests rather than one large risky one: add the nullable column, run the backfill script as a deploy step or scheduled job, ship the dual-write code change, ship the cutover code change, and finally drop the old column. Each one deploys independently through the same migrate deploy command your CI/CD pipeline already runs, so there is nothing exotic about the release process itself, just discipline about not collapsing five safe steps into one risky one.
Treat every migration touching a table with meaningful production traffic as a candidate for this pattern by default, not as a special case you reach for only when something breaks. The extra pull requests are cheap; an incident from a table rewrite during business hours is not.