TL;DR

Django migrations look like simple Python files, but they're actually a dependency graph that Django expects to stay internally consistent across every environment. When different environments record migration history in different orders, things break in ways that are surprisingly hard to fix.

This post covers how cross-app dependencies can create migration states that Django refuses to migrate forward or roll back, why local testing won't catch it, and the manual recovery strategy when you're staring at a broken migration graph.

The root cause? A missing run_before on one side of the dependency, combined with a data migration that had no reverse operation. That's all it took.


You know that feeling when everything works perfectly on your machine, you push to production, and then a completely different environment throws an error that makes zero sense? Django migrations are especially good at that.

They feel like harmless versioned Python files. Generate one, run migrate, move on. But under the hood, Django is building a dependency graph and recording every single event in a django_migrations table. Once different environments start disagreeing about the order of those events, you've got a problem that no amount of --fake flags will easily fix.

When Cross-App Dependencies Seem Like a Good Idea

The story starts with a User model and a new unique_id field. Django generated the migration without issue:

user_0010_add_unique_id_migration.py

All good so far. Then the Orders app needed access to that field. It had to create relations, populate mappings, and run some business logic during the migration itself. So the natural thing was to declare a dependency:

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

This is textbook Django. The docs even have a section on controlling migration order. If anything should feel safe, this should.

The Local Mirage

Ran python manage.py migrate. Passed. No errors, no warnings, nothing to indicate I was planting a landmine for future deployments. That's the thing about local environments: they're excellent at building confidence right before everything goes sideways.

Then a staging server hit this:

InconsistentMigrationHistory:
Migration orders_0001_initial is applied before its dependency
user_0010_add_unique_id_migration

The dependency was correct. The file existed. The graph looked valid. But Django was insisting that the Orders migration had already been applied before its dependency. And to make matters worse, the migration contained custom data logic with no reverse operation defined. The environment was stuck: it couldn't move forward, and it couldn't go back.

A perfectly engineered deadlock.

Why It Didn't Fail Everywhere at Once

Here's the part that really stung. The broken release went out to several sandbox environments, and initially everything looked fine. They were happily running, serving requests, no one noticed a thing. Then those environments tried pulling in newer changes from upstream, and the migration graph finally caught up.

The culprit turned out to be a one-sided dependency. I'd declared dependencies in the Orders migration pointing to the Users migration, but never added the corresponding run_before on the Users side. Depending on the exact state of each environment's django_migrations table, Django could still apply things in an unexpected order.

This is the dangerous part of migrations that people don't talk about enough: you're not just shipping schema changes, you're shipping history. And once different environments have different histories, Django treats that as an invariant it must preserve at all costs.

When Rollback Isn't an Option

First instinct was the standard rollback:

python manage.py migrate orders zero

Django immediately rejected it. The migration had no reverse operation. Past me had walked through a one-way door and assumed someone else would figure out how to build a door going back. That someone was present me, and I had no good options.

The Uncomfortable Recovery

The data in the broken migration was regenerable from existing tables, which meant I could afford to be a bit aggressive. The fix required getting my hands dirty:

  1. Drop the table that the broken migration created.
  2. Remove the migration record from django_migrations.
  3. Explicitly run the Users migration first.
  4. Re-run the full migration sequence.

The SQL looked like this:

DROP TABLE orders_mapping;

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

Then the commands:

python manage.py migrate users
python manage.py migrate

Once the history in django_migrations matched what the dependency graph expected, Django calmed down and normal operations resumed. Not elegant, but it worked.

The Full Picture

Here's the complete chain of events that led to this mess:

  1. Add a field in one app and let Django generate the migration.
  2. Create a cross-app dependency from a second app that needs that field.
  3. Everything passes locally because the migration history aligns.
  4. A different environment has a slightly different history recorded.
  5. Django applies migrations in an order the dependency graph doesn't expect.
  6. The environment enters an inconsistent state.
  7. Attempting rollback fails because no reverse operation was defined.
  8. Manual database surgery becomes the only path forward.

Pro tip: When defining cross-app dependencies, always add the corresponding run_before on the other side. And always define reverse operations for data migrations, even if you're convinced you'll never need to roll back. Future you will thank present you.


Final Thoughts

Django migrations are one of those things that work flawlessly until they don't. And when they don't, the failure modes are unlike anything else in the framework.

The key takeaways:

  • Migration history is application state. It's not just versioned files; it's a record of events that Django expects every environment to agree on.
  • Cross-app dependencies require both sides. A dependencies entry isn't enough without a matching run_before on the other migration.
  • Always define reverse operations. Data migrations without reversals are one-way doors. Make sure you really want to walk through them.
  • Local testing isn't sufficient. Clean environments with fresh databases are the only way to validate that your migration graph works from scratch.

It's one of those things you set up once, understand once, and then never worry about again—until the day a different environment reminds you that your migration history doesn't match what Django expects.