2025-11-16 | PreviewProof Team
Database Migration Patterns That Don't Break Preview Environments
A migration in production runs once. A migration in a preview environment runs every time the environment boots — against a fresh database, dozens of times a day across all your active PRs. Migrations that are fine in production can become preview-environment killers: slow, brittle, dependent on data that doesn’t exist, or written in ways that don’t work from zero.
What’s different about preview migrations
Three properties of preview environments break migration assumptions:
They run from scratch. Migrations that backfill from existing data have nothing to backfill. Migrations depending on a row existing (default tenant, system user) crash.
They run on every boot. A 90-second migration in production runs once. The same migration on every preview adds 90 seconds to every PR’s feedback loop.
They run unattended. Nobody types y at a prompt. Migrations must be fully non-interactive and assume nothing about prior state.
Pattern 1: Idempotent migrations
A migration that crashes if run twice is fine when it runs once. In CI, in development, anywhere it might re-run, idempotency is the difference between “small irritation” and “drop the database and start over.”
-- Bad: fails the second timeCREATE TABLE users (id SERIAL PRIMARY KEY, email TEXT NOT NULL);
-- IdempotentCREATE TABLE IF NOT EXISTS users ( id SERIAL PRIMARY KEY, email TEXT NOT NULL);Most ORMs handle the “only run once” tracking. What they don’t handle is making the migration body safe if tracking goes out of sync — which happens with squashed migrations or branched databases. Use IF NOT EXISTS, IF EXISTS, CREATE OR REPLACE and the equivalents.
Pattern 2: Separate schema migrations from data migrations
Schema migrations are fast and target structural state. Data migrations are slow, depend on existing data, and are often irreversible.
In production, you backfill a column over hours with a controlled script. In a preview booting in 60 seconds with no data, that script is wasted or broken. Keep them apart:
- Schema migration adds the new column with a default. Milliseconds. Safe everywhere.
- Data migration backfills the column. Separate job, only where prior data exists.
- Schema migration N+1 makes the column non-null after backfill. Production-only.
In a preview, the schema migrations run, the data migration no-ops, and the constraint-tightening migration is fine because the seed produces correct data.
# Django example: migration with a separate data stepclass Migration(migrations.Migration): operations = [ migrations.AddField( model_name="user", name="display_name", field=models.CharField(max_length=255, default=""), ), # Skip the data migration in environments with no users migrations.RunPython( backfill_display_names, reverse_code=migrations.RunPython.noop, ), ]
def backfill_display_names(apps, schema_editor): User = apps.get_model("accounts", "User") for user in User.objects.filter(display_name=""): user.display_name = f"{user.first_name} {user.last_name}".strip() user.save(update_fields=["display_name"])The RunPython no-ops when there are no users, so the migration is safe in previews. In production it runs against real users.
Pattern 3: Fast migrations only
A 10-minute production migration is fine because it runs once. Against an empty preview it may run in 50ms. But if a migration does something slow regardless (index builds, partial constraints, defaults applied to seeded data), it becomes a problem.
CREATE INDEX CONCURRENTLYin production, not preview. Concurrent index builds require a separate transaction and are much slower cold. PlainCREATE INDEXon an empty table is instant.- Skip
VALIDATE CONSTRAINTin previews.ADD CONSTRAINT NOT VALIDthenVALIDATEis a production optimization. In previews, justADD CONSTRAINT. - Avoid full-table scans on seeded tables. No-ops on empty tables, slow once your seed has populated them.
Profile your migration step. If it takes more than 30 seconds against your seed, that’s budget eaten before the app starts.
Pattern 4: Test migrations against preview-shaped data
Migrations that break in previews usually worked fine in dev (where the developer had production-shaped data) and broke against the synthetic seed. Fix: run the migration in CI against the actual seed.
- name: Apply schema migrations from scratch run: | createdb -h localhost test_preview npx prisma migrate deploy npm run seed npx prisma migrate statusCatches the “I added a non-null column with no default and the seed doesn’t populate it” class of bug before any reviewer sees a broken preview.
Framework-specific gotchas
Rails
schema.rb vs structure.sql. Default schema.rb is database-agnostic and roundtrips through ActiveRecord, losing information (CHECK constraints, partial indexes, generated columns). For Postgres-specific features, switch:
config.active_record.schema_format = :sqldb:schema:load then reproduces the exact schema. Previews boot from db:schema:load far more often than production does, so the precision matters.
Django
The migration graph is your friend and your enemy. Two gotchas:
- Squashing breaks if data migrations depended on intermediate state. Test squashed migrations against an empty database.
makemigrationsin CI should never run in a preview boot. Lock the entrypoint tomigrateonly.
Phoenix / Ecto
Ecto migrations are ordered by timestamp. change/0 infers the down migration only for operations it understands; custom execute/1 SQL needs explicit up/down. In previews you only run forward. Separate priv/repo/migrations/ (schema) from priv/repo/seeds.exs (data).
Laravel
php artisan migrate --force is required in non-interactive environments. Without --force, Laravel prompts and crashes with no TTY:
php artisan migrate --force --no-interactionphp artisan db:seed --force --no-interactionUse migrate, not migrate:fresh. The latter drops and rebuilds — disastrous if run anywhere shared.
What to do when migrations are genuinely too slow
If you’ve done the above and migrations are still too slow, bake the schema into a base database image rather than running migrations at boot.
CI applies migrations to a Postgres image, dumps the data directory, and publishes a new tag. Previews pull the image and start with the schema already in place — no migrations at boot, just the seed step. Image rebuild gates on changes to migrations/, so most PRs reuse the cached image. How to seed Postgres for ephemeral previews covers the pattern.
The bottom line
Migrations in preview environments need to be fast, idempotent, non-interactive, and tolerant of empty databases. The patterns aren’t exotic but they require discipline — and running the migration step in CI on every PR so “broken in preview” surfaces before a reviewer clicks a link.
If you’d rather have the preview pipeline handle migration application, retry on transient failures, and keep your fresh-database boot under a minute, PreviewProof runs the migration step on every preview, with caching, parallelism, and the option to bake the schema into a base image when the seed is too slow. The migration code stays yours. The orchestration isn’t your problem.