An unorthodox choice
Django's documentation is unambiguous: migrations are source code, you commit them, they travel with the app. Almost every Django project does exactly that, and for good reason.
Our main platform does not. Across 49 apps, the repository contains zero migration files. Every environment — a developer laptop, staging, each production instance — runs makemigrations and migrate for itself.
This article is not a recommendation. It is an honest account of how we ended up here, what it actually costs, and the two traps that bit us — because if you are considering the same thing, the traps are the part worth knowing in advance.
How we got here
The short version: our database schema and our migration history drifted apart years ago, and committed migrations stopped describing reality.
The clearest example came from one module where migrations were committed. The index names inside the committed 0001_initial did not match the index names in the actual database, because the database had been created by a different, earlier auto-generated migration. Django noticed the mismatch and helpfully generated a RenameIndex operation — which then failed on every deploy:
relation "eff_ans_resp_idx" does not exist
On top of that, the committed migration carried a hardcoded dependency on another app's migration — and that app's migrations were already gitignored, so the dependency pointed at a file that existed on nobody's machine but the author's.
We cleaned it up by dropping the module's tables and its django_migrations rows, deleting the committed files, and letting the next deploy regenerate everything from scratch. One CreateModel sequence, no ordering problems, no renames of things that were never named that way.
That is the pattern that pushed us to the convention: when the schema is the source of truth and the migration history is fiction, committed migrations do not protect you — they actively get in the way.
Trap one: the package still has to exist
The rule is "ignore migrations", but the .gitignore entry for every app looks like this:
apps/candidates_app/migrations/*
!apps/candidates_app/migrations/__init__.py
That second line is not decoration. Django decides whether an app is "migrated" by whether it has a migrations package. No package, no migrations — and Django then falls back to sync_apps, which creates the tables directly, before the migration phase runs.
On an existing database nothing happens and you never find out. On a fresh one, the unmigrated app's tables get created first, and any foreign key pointing at a table that migrations have not built yet blows up.
We rediscovered this in August, porting a module between branches. Everything came across except one empty __init__.py. Local development was fine — the database already existed. The CI build, which starts from an empty database, failed on a foreign key to our base app. An empty file, a failing pipeline, and no obvious connection between them.
So the convention is not really "do not track migrations". It is "track the package, ignore its contents" — and the distinction is invisible until it costs you an afternoon.
Trap two: ordering is now your problem
Committed migrations encode a dependency graph. Throw them away and you throw that away too.
Most of the time makemigrations && migrate just works. Occasionally it does not — typically when a model in one app holds a PROTECT foreign key into another and the two get generated in an unfortunate order. The workaround is to stop asking Django to do everything at once and migrate a single app:
python manage.py migrate efficiency_app
This is a real cost. With committed migrations that ordering is decided once, by the author, on their machine. With generated ones it is decided again on every environment, and occasionally it is decided badly. It is a known, documented step in our deployment notes — which is another way of saying we pay it regularly.
What we gave up
Being fair about the other side:
- Schema changes are invisible in code review. A reviewer sees the model diff, not the DDL. Adding an index to a large table, or a column with a default that rewrites every row, goes through review looking like a two-line change.
- Environments can diverge. Two instances that generated their own migrations at different times have different files with the same numbers. They usually converge on the same schema — usually.
- No data migrations for free. Anything that has to transform existing rows needs a deliberate, separate mechanism, because you cannot rely on a checked-in migration to carry it.
- Rollback is not a migration away. With no history in the repo, "revert to last week's schema" is a database job, not a
migratejob.
Every one of those is a real argument for the standard approach, and for a new project we would follow the documentation without hesitating.
When this trade is worth it
Our situation has three properties that make it defensible, and if yours lacks them the calculation is different:
- The schema, not the migration history, has been the source of truth for years. Reconstructing an honest history would be archaeology.
- Deployments are done by people who can read a
migratefailure and act on it. This convention needs a human near the deploy. - Data migrations are rare. When we need one, it is a conscious, scripted operation, not a side effect of a schema change.
What we would not claim is that this is a better default. It is a reasonable answer to a specific history, with two sharp edges — the package marker and migration ordering — that cost real time before we learned where they were.
If you inherit a project where migrations and schema have diverged, this is one way out. Just go in knowing that "ignore the migrations" is a lie by omission: what you actually maintain is a careful set of exceptions around them.
