Squashing Migration History Without Breaking Environments

Your migrations/ directory has grown to 400 files, a fresh CI database takes eleven minutes to build from scratch, and a new laptop replays six years of dropped columns before it can run a single test. So you squash the chain: delete the old files, commit one clean baseline, and push. CI goes green because CI starts empty. Then the production deploy aborts with a schema-history table that still remembers all 400 versions and now cannot find any of them on disk. Staging, which was rebuilt last week from a different snapshot, records a different cut-off point, so the two environments no longer agree on what “already applied” means. Squashing is safe only when every environment’s history ledger is reconciled with the new baseline in the same operation — this page shows how to collapse the chain without desyncing prod and staging, and sits under schema version control basics in the migration fundamentals guide.

Symptom / Error Signatures

The failure never appears on the empty database you tested against. It appears on the environments that already ran the old chain:

  • Detected applied migration not resolved locally: 1 (Flyway — the DB recorded a version whose file you just deleted)
  • Validate failed: Migrations have failed validation. Detected applied migration not resolved locally
  • Found non-empty schema(s) "public" but no schema history table. Use baseline() or set baselineOnMigrate to true (a rebuilt environment that lost its ledger)
  • django.db.migrations.exceptions.InconsistentMigrationHistory: Migration app.0002_squashed is applied before its dependency app.0001_initial
  • ActiveRecord::DangerousAttributeError / Multiple migrations have the version number 20260101000000 (Rails, after a hand-edited timestamp)
  • liquibase.exception.ValidationFailedException: Validation Failed: 1 change sets check sum (Liquibase changelog checksum drift after a rewrite)

The common thread: the physical schema is correct, but the recorded history no longer matches the files on disk, and different environments disagree about where the truncation happened.

Root Cause Analysis

A squash rewrites the files but the migration runner trusts the history table, and those two live in different places. Every environment carries its own copy of the ledger — flyway_schema_history, django_migrations, schema_migrations, DATABASECHANGELOG — recording exactly which versions it has applied. Deleting the old files removes the left-hand side of a comparison the runner performs on every deploy: for each row in the history table, does a resolvable file still exist? After a naive squash the answer is no, and a strict runner refuses to proceed rather than risk a divergent schema.

The desync between environments is a second, worse failure. If prod last applied version V0399 and staging was rebuilt from a snapshot that only reached V0350, the “safe cut-off” for a baseline differs per environment. A single global baseline at V0400 is a lie on staging, which never ran 03510399. Tools split sharply on how forgiving they are here, which is one of the lock-and-history trade-offs surveyed in the migration tool comparison.

Concern Flyway / Liquibase (SQL-first) Django / Rails (ORM-first)
How a squash is expressed New baseline file + flyway baseline; delete old versioned SQL manage.py squashmigrations; keeps a replaces = [...] list
Old files can be deleted Yes, after every environment is baselined past them Not until every environment has applied the squashed node
Reconciliation mechanism baselineVersion / flyway repair rewrites history rows replaces maps old node names to the squashed node automatically
Failure if skipped Detected applied migration not resolved locally InconsistentMigrationHistory on the lagging environment
Safest cut-off rule The lowest version applied across ALL environments The lowest squashed node applied across ALL environments

The rule both columns imply is the same: the baseline can only sit at a version that every environment has already passed. The ORM tools automate the bookkeeping with a replaces marker; the SQL tools make you drive baseline and repair yourself. Neither will save you if staging and prod are at different points and you baseline above the laggard.

Where a shared baseline can safely sit A single global baseline is only valid at the lowest version every environment has applied. Prod reached V0399 and staging only V0350, so V0350 is the safe floor and V0400 would strand staging. staging at V0350 prod at V0399 version V0350 V0399 V0400 SAFE baseline = V0350, the min across all UNSAFE = V0400 above staging — strands it
A global baseline is trustworthy only at the lowest version every environment has passed: V0350 here. Baselining at V0400 leaves staging with a floor it never reached.

Immediate Mitigation

Run these from a privileged migration terminal against one environment at a time, deploys halted, starting with the environment that is furthest behind. Take a backup of each history table first — it is tiny and it is your only undo.

  1. Snapshot every environment’s cut-off so you can compute the safe baseline. The lowest applied version across all environments is the only version you may baseline at.
-- PostgreSQL · read-only · run against prod AND staging AND any lagging replica
-- The highest version each environment has actually applied.
SELECT max(version::int) AS highest_applied
FROM flyway_schema_history
WHERE success = true;
  1. Back up the ledgers before touching them. This is a low-write-window operation only because you must pause deploys, not because the copy is heavy.
# Context: privileged migration role · run once per environment · non-destructive
# PostgreSQL — preserve the exact history rows in case reconciliation must be reverted.
pg_dump -h "$HOST" -U migrator -d app -t flyway_schema_history \
  --data-only > "history_backup_${ENV}.sql"
  1. Author the baseline from the schema, not the chain. Dump the current structure of the lowest-common environment and commit it as the new floor. Follow idempotent script design so a re-run is harmless.
# Context: read-only against the environment at the LOWEST cut-off · migration role
# PostgreSQL — schema only; this file becomes the immutable baseline everyone shares.
pg_dump --schema-only -h "$HOST" -U migrator -d app \
  > migrations/B0350__baseline.sql
  1. Baseline each environment at the common floor, then let the runner re-scan. Flyway marks the baseline as applied and stops validating anything below it.
# Context: production deploy step · migration role · runs OUTSIDE any open transaction
# Reconcile the ledger to the new floor, then validate before applying anything above it.
flyway -baselineVersion=0350 -baselineDescription="baseline" baseline
flyway repair   # rewrites/clears rows for deleted versions below the baseline
flyway validate && flyway migrate
  1. Verify the ledgers agree before resuming deploys. Every environment must now show the same baseline row and the same set of post-baseline versions.
-- PostgreSQL · read-only · run on each environment and diff the outputs
SELECT version, description, type, success
FROM flyway_schema_history
WHERE installed_rank <= 2
ORDER BY installed_rank;
The mitigation runs under a deploys-halted lock Snapshot every environment's cut-off, back up each history ledger, dump the baseline at the common floor, then baseline and repair each environment and validate. All four steps run with deploys paused. DEPLOYS HALTED 1 Snapshot every environment's cut-off the minimum applied version is the only safe baseline 2 Back up each history ledger the tiny copy is your only undo 3 Dump the baseline at the common floor schema-only, from the lowest-cut-off environment 4 Baseline + repair each environment then validate before applying anything above it
Work one environment at a time, furthest-behind first, with deploys paused for the whole sequence so no deploy races the ledger rewrite.

Permanent Fix / Long-Term Pattern

The durable fix is to make squashing a scheduled, coordinated operation rather than a cleanup someone does on a Friday. First, only ever baseline at a version every environment has provably passed — enforce it with a CI check that reads each environment’s highest applied version and refuses to merge a baseline above the minimum. Second, keep the squashed old files in the tree for one full release cycle behind a replaces/baseline marker so a rollback or a slow environment can still resolve them; delete them only after a verification pass confirms no ledger references them. In ORM tools this is exactly what manage.py squashmigrations plus the generated replaces = [...] list gives you — the squashed node stays valid for databases at either the old or the new state, and you remove the originals in a later, separate commit.

Because a baseline is only trustworthy if staging and prod started from the same structure, squashing is downstream of your environment parity strategy: if dev and prod schemas have already drifted, a single baseline will bake that drift in permanently. Treat the baseline itself as an immutable, checksummed migration under the same rules as any other file — never hand-edit it after an environment has adopted it — and when a squash collides with in-flight branch work, reconcile the numbers using the procedure in resolving migration version conflicts during merges. For the ordering and checksum contract a baseline must honor, the parent schema version control basics section is the deeper reference.

What a squash collapses, and what still resolves the old state The old chain of roughly 400 files that made CI take eleven minutes is replaced by a single baseline plus a short post-baseline stack, cutting the build to seconds. A replaces marker keeps the old files resolvable during the transition. BEFORE AFTER ≈ 400 migration files CI build: 11 min replaces B0350 baseline immutable floor + post-baseline files CI build: seconds
The squash collapses the long chain into one baseline plus the files applied since. Keeping the old files behind a replaces marker for a release cycle lets a lagging database still resolve its recorded history.

Verification Checklist

Frequently Asked Questions

Can I just delete the old migration files and commit a baseline? Not on any environment that already ran them. The history table still holds a row for every deleted version, and a strict runner aborts with Detected applied migration not resolved locally because it can no longer find the file it recorded. You must reconcile the ledger — via flyway baseline plus flyway repair, or a Django replaces marker — in the same operation, on every environment, before removing the files for good.

What version should the baseline sit at when staging and prod are at different points? The lowest version any environment has applied. If prod is at 0399 and staging at 0350, baseline at 0350; anything higher strands staging with a floor it never reached and throws InconsistentMigrationHistory or no schema history table on the next deploy. Query each environment’s highest applied version first and take the minimum.

Is squashing safe to run during business hours? Treat it as a low-write-window change with deploys paused. The baseline and repair steps rewrite the history table, and a concurrent deploy racing that rewrite can record a half-reconciled ledger that neither matches the files nor the other environments. The physical schema is untouched, so there is no long lock, but the coordination window must be exclusive.