Data migrations are essential for one-off tasks like backfilling fields or restructuring data. But once they’ve run in production and all your environments, the code inside them becomes dead weight.
We often leave them in the codebase because we’re afraid to break the migration history, but keeping them around causes more problems than it solves.
The Problem with Old Migrations
Old data migrations are a common source of test failures and maintenance headaches.
When you run your test suite, Django creates a test database and runs all migrations. This means scripts from three years ago are still executing today. Ideally, if you use apps.get_model correctly, migrations should be isolated from your current code. However, in long-lived projects, this isn’t always enough. Logic often drifts, and you might find yourself fixing a migration script from 2023 that references a field you renamed today just to get your tests to pass.
You end up maintaining code that hasn’t been relevant for years. It’s a technical debt that occasionally bites you when you least expect it.
Git Has the History
A common argument for keeping the code is “historical context.” We want to know how the data got into its current state.
But that is exactly what version control is for. If you need to know how a specific column was populated five years ago, you can check git blame or look through the commit history. The active codebase doesn’t need to carry that burden.
Once a migration has successfully run everywhere, the code itself is no longer strictly necessary. Only the record that it ran matters.
A Pragmatic Option: The No-Op Migration
If your old migrations are becoming a burden, one way to handle them is to replace their logic with a no-op. You can’t simply delete the migration file because Django relies on it to maintain the dependency graph. If you delete a file in the middle of the chain, makemigrations will get confused.
A pragmatic middle ground is to keep the file but delete the logic. Django provides migrations.RunPython.noop specifically for this purpose. This isn’t necessarily “the right way” for every project, but it is a solid tool to have in your kit when migrations start slowing you down or breaking your builds.
You can replace the complex operation with a no-op:
# The file stays to preserve the graph, but the code does nothing.
operations = [
migrations.RunPython(
migrations.RunPython.noop,
migrations.RunPython.noop,
),
]
This keeps the migration graph intact. Django sees the migration as existing and applied, but the code inside is gone. It won’t slow down tests, it won’t break when models change, and it stops adding noise to your development process.
Summary
Maintaining a long-lived Django project often requires making trade-offs between strict historical accuracy and developer productivity. Converting problematic old data migrations to no-ops is a useful option for keeping your test suite fast and your codebase free from irrelevant legacy logic.