One fine working day, I decided to do something that sounded perfectly reasonable: add a field in one Django app, then use that field inside a migration in another app. Easy.

Django even supports this officially: controlling-the-order-of-migrations. Which, in retrospect, should have been my warning sign.

The Setup

Suppose we have a User model. We introduce a shiny new field unique_id; Django happily generated:

user_0010_add_unique_id_migration.py

So far, everything was calm and peaceful.

Then came the Orders app. Orders needed to:

  • Create relations using User.unique_id
  • Populate existing order mappings
  • Execute some business logic during migration

So naturally, the migration depended on the Users migration:

dependencies = [
    ("users", "0010_add_unique_id_migration"),
]

Nothing unusual here. Just standard dependency graph behavior.

Everything Worked Locally

I ran python manage.py migrate, everything worked. Of course it did. Local environments are excellent at building confidence right before disaster. No errors. No warnings. No indication that I was creating a future archaeology problem. Then another environment exploded with this:

InconsistentMigrationHistory:
Migration orders_0001_initial is applied before its dependency
user_0010_add_unique_id_migration

Which made no sense at first,

  • The dependency existed.
  • The migration file was correct.
  • The dependency graph looked valid.

But somehow, in that environment, Django had recorded the migrations in a different order. That was the first weird part. The second weird part was worse. The migration also contained custom data migration logic, and I hadn’t defined a reverse migration.

So the environment had entered a broken migration state that Django refused to move forward from and refused to roll back from.

A beautifully engineered deadlock.

The Real Problem Appeared Later

The truly annoying part was that this didn’t fail immediately everywhere. The broken release had already made its way into a few sandbox environments, and initially everything looked completely healthy. Then those environments tried upgrading with newer changes from upstream, and suddenly the migration graph came back for revenge.

Turns out I had only defined the dependency from the Orders migration side, but forgot to add a corresponding run_before in the Users migration. So depending on the migration state of a particular environment, Django could still end up applying things in an unexpected order. Which is exactly what happened.

Some environments had already recorded migrations in a sequence that no longer matched what the newer codebase expected. This is the dangerous thing about migrations: you’re not just shipping schema changes, you’re shipping historical events. And once different environments start recording history differently, Django becomes extremely strict about preserving that timeline.

Attempting a Rollback

Naturally, I tried the obvious thing:

python manage.py migrate orders zero

Django rejected that immediately because the migration had no reverse operation defined. Which was fair. Past me had basically handed Django a one-way door and expected it to invent a staircase later.

The Recovery

Fortunately, the migration didn't contain irreplaceable data. Everything could be regenerated safely from existing tables. So I resorted to the classic engineering recovery strategy:

If the migration graph is cursed enough, become slightly cursed yourself.

The fix ended up being:

  1. Drop the broken table manually
  2. Delete the migration entry from django_migrations
  3. Explicitly run the Users migration first
  4. Re-run migrations normally

The PostgreSQL cleanup looked something like this:

DROP TABLE orders_mapping;

DELETE FROM django_migrations
WHERE app = 'orders'
AND name = '0001_initial';

Then:

python manage.py migrate users
python manage.py migrate

Hacky? Absolutely.

Effective? Also yes.

Once the migration history matched the dependency graph again, Django finally calmed down and upgrades started working normally.

Final Thoughts

Django migrations feel deceptively safe most of the time. You generate them, run migrate, and everything just works.

But once custom migration logic and cross-app dependencies enter the picture, migration history itself becomes part of your application state.

The tricky part is that these problems rarely show up immediately. They appear later after upstream merges, partial deployments, or environments that evolved slightly differently from your local setup.

And when that happens, you quickly realize that migrations are not just versioned Python files. They are a dependency graph that Django expects to remain internally consistent forever.

  • Cross-app dependencies are fragile, they may work perfectly locally while still breaking in environments with different migration histories.

  • Never assume migration order is stable — especially when multiple branches or upstream merges are involved.

  • Always define reverse operations for custom migrations even if you think you'll “never need to rollback”. Eventually, you probably will.