PostgreSQL and Prisma
Prisma Migrations Without Production Surprises
A deployment-focused guide to expand-and-contract schemas, ordering, backfills, locks, constraints, observability, and realistic rollback planning.
A migration can be valid SQL and still be unsafe for production. The danger is rarely that Prisma cannot generate a statement. It is that the statement runs against a large, active database while old and new application versions overlap, workers continue processing, constraints inspect existing data, and a rollback needs the schema that was just changed. Production safety comes from deployment design, not from treating schema generation as a complete plan.
Custom Name Domain is a useful context because integration-heavy systems persist orders, subscriptions, domains, DNS steps, and mailbox provisioning across long workflows. A column change can affect HTTP handlers, webhooks, scheduled jobs, and recovery tools at different times. The database is not paused while the new application starts. A safe migration lets both versions coexist, makes data movement observable, and delays destructive cleanup until evidence shows it is no longer needed.
Prisma Migrate provides versioned SQL history and a production deploy command. Those are important foundations. The engineering task is to review and sequence the generated history so every intermediate state remains valid.
Treat migration history as production source code
Commit the entire migrations directory, not only the Prisma schema. Prisma documents that production deployment applies migration files and that customized SQL cannot be reconstructed from the final declarative model. Editing or deleting an already applied migration makes environments disagree about history even if their current schemas appear similar.
Review migration SQL like application code. Identify table rewrites, full scans, lock modes, index builds, defaults, type conversions, constraint validation, and data loss. Estimate the affected row count and query traffic. Generated SQL optimizes for reaching the desired schema, not necessarily for doing so under the application's uptime requirements.
Give each migration one understandable purpose. Combining unrelated table changes makes failure analysis and rollout control harder. Use descriptive names and include operational notes when execution ordering, manual checks, or a backfill are required. The repository should explain why a customized statement differs from the generator output.
Never resolve a history mismatch by resetting production. Prisma's development reset workflow is destructive and belongs to disposable databases. Production uses migrate deploy to apply pending migrations. If history drift appears, investigate which files and database records changed, restore the canonical history, and use an explicit repair plan.
Test the complete history from an empty database and from a realistic previous snapshot. Both matter: a clean replay proves new environments work, while the upgrade path reveals data and lock behavior.
Expand before asking new code to depend on change
The safest rollout usually starts with an additive schema. Add a nullable column, a new table, or a permissive enum path that old application instances can ignore. Deploy that migration first. Only after it succeeds should new code begin reading or writing the new structure.
Prisma's expand-and-contract guidance demonstrates replacing a field through stages rather than one destructive change. The exact stages vary, but compatibility is the principle. If replacing a boolean with a status enum, first add the status while keeping the boolean. New code can dual-write or derive one from the other. Backfill existing rows. Switch reads after validation. Remove the old field in a later release.
Avoid adding a required column with an expensive computed default to a busy large table without understanding database behavior. A nullable addition followed by batched population and later constraint validation gives more control. For a new relation, create the referenced rows and nullable foreign key before requiring every existing record to have one.
Application types must tolerate the expanded state. During rollout, the new column may be null and the old field may remain authoritative. Make that temporary policy explicit in code. A non-null TypeScript assertion does not change production data.
Feature flags can separate deployment from activation. Ship compatibility code, observe it against the expanded schema, then enable new writes for a small cohort before broad adoption.
Order schema, application, workers, and cleanup deliberately
A production release is a sequence, not one atomic event. Write the order down. A common sequence is expand schema, deploy compatible application, enable dual writes, run backfill, validate, switch reads, stop old writers, enforce constraints, and contract schema. Each step needs an entry condition and a verification query.
Background workers are easy to forget because they may deploy separately or keep old code alive longer than web instances. Inventory every writer: APIs, server actions, webhooks, cron jobs, queues, admin scripts, and data exports. Do not remove or reinterpret a field until all old writers are stopped and any delayed messages they produced are handled.
Rolling deployments mean old and new application versions overlap. New code must work before the backfill completes, and old code must work after the expansion. If compatibility cannot be achieved, schedule a controlled maintenance window rather than pretending the deploy is zero downtime.
Separate schema deployment from application startup when possible. Prisma recommends running migrate deploy in automated delivery for production rather than relying on a developer's local command. One controlled migration job should finish before instances that require the schema receive traffic. Advisory locking helps prevent concurrent Prisma migration commands, but it does not design application compatibility for you.
Record who initiated each stage, the deployed commit, migration version, start and finish time, and validation result. That evidence shortens incident response.
Backfill in bounded, restartable batches
Backfills are production workloads. A single transaction updating every row can hold locks, grow transaction logs, increase replication lag, block vacuum work, and make failure expensive. Process bounded batches ordered by a stable key. Commit each batch and persist a checkpoint or derive the next range from data state.
Make the transformation idempotent. Updating rows where the new field is null lets a failed run restart. If source data can change during the backfill, dual-write first or use a comparison rule that does not overwrite newer values. Store transformation errors separately rather than retrying poisoned rows forever.
Throttle based on database health, not only a fixed sleep. Observe query latency, lock wait, CPU, connection use, replication lag, and application error rate. Pause automatically when thresholds are exceeded. Run a representative rehearsal to estimate time, but expect production traffic and data distribution to differ.
Do not load an entire table through Prisma Client and update rows one by one inside one long transaction. For simple transformations, reviewed SQL batches may be more efficient. For domain logic requiring application code, page through stable identifiers and keep each transaction short. Limit concurrency so workers do not contend on adjacent rows.
After completion, verify counts, null rates, value distributions, referential matches, and sampled semantic equivalence. Completion means the new representation is correct, not merely that the script reached the end.
Understand locks before enforcing constraints
Schema changes acquire database locks, and the exact mode determines which production queries wait. PostgreSQL documents a range of table and row lock modes; an access-exclusive operation can block ordinary reads. Even a fast statement can wait behind a long transaction, then cause a queue of blocked work when it finally acquires its lock.
Inspect active long transactions before deployment and use lock and statement timeouts appropriate to the operation. It is often safer for a migration to fail quickly and be retried in a controlled window than to wait indefinitely while application requests accumulate. Monitor blocked sessions during execution.
Create large indexes using the database's online or concurrent capability where appropriate, which may require customizing generated SQL and understanding transaction restrictions. Add constraints in stages when the database supports separating creation from validation. First stop new invalid data, repair existing rows, validate, then make the application type stricter.
Foreign keys need indexes on referencing columns for common joins and deletes, even though the database may not create them automatically. Unique constraints can fail because of historical duplicates; query and resolve them before deployment. Type changes may rewrite a table or reject values that the application previously accepted.
Prisma is the schema interface, but PostgreSQL is the engine executing locks and scans. Production review needs both models.
Validate constraints after proving the data
Changing a field from optional to required is an assertion about every current row and every concurrent writer. Prove it in layers. Backfill nulls, add application validation, stop old writers, query for violations, then add the database constraint. Keep a verification query in the runbook and capture its result.
For enums and state machines, adding a value is usually easier than removing or renaming one. Deploy code that understands both old and new values, migrate data, then remove the old path later. Avoid coupling a schema enum change and exhaustive application switch in a way that causes either version to crash on the other's value.
For uniqueness, define the correct business scope. In a multi-tenant system, a slug may be unique by tenant rather than globally. Changing the constraint can affect query methods generated by Prisma Client, API conflict handling, and idempotency behavior. Test concurrent inserts, not only existing duplicates.
For relationships, confirm orphan policy. A new foreign key may reveal legitimate legacy records or corrupted data. Decide whether to repair, archive, map to a sentinel, or reject the migration. Do not delete evidence merely to make the constraint pass.
Once constraints are active, keep application checks for user-friendly errors while relying on the database for race safety. Translate known constraint failures into conflicts and alert on unexpected ones.
Instrument the rollout and define stop conditions
Before starting, define success and abort thresholds. Monitor migration status, lock wait, query latency, error rate, connection saturation, replication lag, job backlog, and business transition failures. Add a deployment marker so changes can be correlated with symptoms.
Backfill dashboards should show scanned, changed, skipped, failed, remaining, batch duration, and estimated completion. Estimates are advisory; correctness counts matter more. Sample both transformed and untouched rows. Compare application reads from old and new representations during dual-read validation, logging mismatches without exposing sensitive data.
Watch beyond migration completion. New query plans, index choices, serialization costs, and cache keys may change only under real traffic. Keep the expanded compatibility path available through an observation window. Do not contract immediately because the first minutes look healthy.
Make stop controls safe. The backfill should stop between batches, not by killing an unbounded transaction. Feature flags should disable new behavior without reverting the schema. Workers should drain or pause with visible state. Document how to resume without repeating irreversible effects.
An uneventful rollout produces evidence: migration applied once, both application versions remained compatible, backfill reconciled, constraints validated, error budgets stayed healthy, and cleanup was intentionally scheduled.
Plan rollback as a forward-compatible transition
Application rollback and schema rollback are different. Rolling back code is straightforward only when the old code can still operate on the expanded schema and understands any new data written. That is another reason to keep old fields and avoid destructive changes in the same release.
Down migrations can be dangerous after production writes use a new schema. Dropping a column to reverse a deployment may destroy data and still not restore external side effects. Prefer rolling application behavior back while leaving additive schema in place, then diagnose and create a new forward migration.
For each stage, state the rollback action. Before new writes, application rollback may be enough. During dual write, preserve both representations and disable the new path. During backfill, stop batches and keep checkpoints. After read switch, revert reads only if old data stayed synchronized. After contraction, restoration may require backup and a maintenance event, which is why contraction should be delayed.
Backups are necessary but not a convenient rollback mechanism. Verify restore procedures and recovery time on realistic data. A snapshot does not undo provider calls or messages after its timestamp. Domain reconciliation remains necessary.
Use incident language honestly: sometimes the safest response is pause, contain, and roll forward with a corrective migration rather than execute a rehearsed-looking down script.
Contract only after the evidence window closes
Cleanup removes old columns, compatibility branches, dual writes, flags, and temporary indexes. It reduces maintenance burden, but it is the destructive phase and should be a separate release. Confirm no old application instances or workers remain, delayed messages are exhausted, dashboards use the new representation, and rollback policy no longer depends on the old field.
Search the codebase, queries, analytics, exports, admin tools, and runbooks for the old name. Observe database reads if tooling allows. Remove application reads first, then stop writes, then remove schema. Keep migration history intact even after the old field disappears.
Review the final Prisma schema and generated client usage. Temporary optional types may now become required. Delete dual-write comparison metrics only after they have shown stable agreement. Archive the rollout record with validation counts and any follow-up decisions.
Safe migrations are intentionally slower in calendar time because compatibility phases create evidence. They are faster in operational time because they avoid emergency repair, long outages, and uncertain rollback. Prisma supplies a disciplined migration history and deployment workflow; production readiness comes from combining it with database-aware locks, bounded data movement, explicit ordering, observability, and a willingness to delay destructive cleanup.
Primary sources
- 1.Expand-and-contract migrations — Prisma
- 2.Prisma Migrate development and production — Prisma
- 3.About migration histories — Prisma
- 4.PostgreSQL Explicit Locking — PostgreSQL
Portfolio evidence
Related writing