2025-11-16 | PreviewProof Team

Database Migration Patterns That Don't Break Preview Environments

database migrationspreview environmentsRailsDjangoLaravelPhoenix

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 time
CREATE TABLE users (id SERIAL PRIMARY KEY, email TEXT NOT NULL);
-- Idempotent
CREATE 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 step
class 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 CONCURRENTLY in production, not preview. Concurrent index builds require a separate transaction and are much slower cold. Plain CREATE INDEX on an empty table is instant.
  • Skip VALIDATE CONSTRAINT in previews. ADD CONSTRAINT NOT VALID then VALIDATE is a production optimization. In previews, just ADD 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 status

Catches 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 = :sql

db: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.
  • makemigrations in CI should never run in a preview boot. Lock the entrypoint to migrate only.

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:

Terminal window
php artisan migrate --force --no-interaction
php artisan db:seed --force --no-interaction

Use 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.