# django-migration-linter: catching migrations that break older code

> django-migration-linter inspects a Django project's migrations and flags the ones that would break a deployment still running an older version of the code: a dropped table, a renamed table, a new not null column. It runs as a command over the whole history, and it can also be wired into the migration generator so that a bad migration is caught at the moment it is written.

**David-Wobrock/django-migration-linter** — :rocket: Detect backward incompatible migrations for your django project

- Repository: https://github.com/David-Wobrock/django-migration-linter
- Website: https://pypi.python.org/pypi/django-migration-linter/
- Stars: 623 · Forks: 68
- Language: Python
- License: Apache-2.0
- Published: 2026-10-22 · Updated: 2026-10-22 · Language: en
- Canonical page: https://hysenlabs.com/projects/david-wobrock-django-migration-linter

## The problem the linter addresses

django-migration-linter detects backward incompatible migrations for a Django project, and the readme states the benefit plainly: it will save you time by making sure migrations will not break with an older codebase.

That phrase, an older codebase, is the whole problem. Django's migration system assumes that the database schema and the code that talks to it move together, and in development that is true because the schema is rebuilt from scratch. In production it is frequently not true, because a deployment replaces the running instances one at a time, and for a while the old code is querying a database whose schema has already moved on.

The classic example is adding a not null column. The migration adds the column and requires a value for the existing rows. The old code does not know that column exists, which is fine, but the moment the new code is deployed and begins writing rows with the new column, an old instance reading those rows and writing them back will fail, because it does not know how to supply the new required value.

The other classic example is dropping a table. The new code no longer uses it. The old code still does. The moment the migration runs, the old instance fails on every request that touches it.

Neither of these is a mistake in the migration. Both are correct migrations for a codebase that is deployed atomically. They are only mistakes in a deployment model where the old code runs against the new schema, and that is the model almost every real service has.

The linter's job is to tell you which of your migrations are of that kind, so that you can either write them differently or acknowledge them.

That last part is important, and the linter is explicit about it. A migration can be marked as ignored, and the example output shows one such migration with a status of ignore rather than an error. The tool is not a policy enforcer that says no; it is a report that says here are the places where you are relying on an assumption.

The project is Apache 2.0 licensed, the last push was on 2026-10-19, and the release history shows a recent major version, with the package requiring a recent Django and a recent Python.

## Installing it, and the two ways to run it

Installation is a package manager install and one line in the settings.

```bash
pip install django-migration-linter
```

```
INSTALLED_APPS = [
    ...,
    "django_migration_linter",
    ...,
]
```

Adding it to the installed applications is what gives you the management command, and the configuration is optional, in the same settings file, under a name that follows the framework's convention for a dictionary of options.

The command itself is a management command, so it runs through the project's own management script, which is what makes it work in an environment where the settings are configured.

```
$ python manage.py lintmigrations

(app_add_not_null_column, 0001_create_table)... OK
(app_add_not_null_column, 0002_add_new_not_null_field)... ERR
        NOT NULL constraint on columns
(app_drop_table, 0001_initial)... OK
(app_drop_table, 0002_delete_a)... ERR
        DROPPING table
(app_ignore_migration, 0001_initial)... OK
(app_ignore_migration, 0002_ignore_migration)... IGNORE
(app_rename_table, 0001_initial)... OK
(app_rename_table, 0002_auto_20190414_1500)... ERR
        RENAMING tables

*** Summary ***
Valid migrations: 4/8
Erroneous migrations: 3/8
Migrations with warnings: 0/8
Ignored migrations: 1/8
```

The output format is worth pausing on, because it teaches you the tool in thirty seconds. Each migration is reported with its application and its name, and it gets one of four statuses: it is fine, it is erroneous, it produced a warning, or it was ignored. Underneath the lines is a summary with the counts of each category, so a run over a project with two hundred migrations gives you a number to watch rather than a wall to scroll.

The error messages are short and specific: a not null constraint on columns, dropping a table, renaming tables. That is a good level of detail, because the message tells you which rule fired and the documentation has a page listing every rule.

So the tool answers two questions at once: does this project contain a dangerous migration, and which one. And the ignored category means the answer to the second question can be adjusted, deliberately and visibly.

## Catching it at generation time instead

The second integration is the more interesting one, because it moves the check from a separate step to the moment the problem is created.

The migration generator can be asked to lint what it has just written.

```
$ python manage.py makemigrations --lint

Migrations for 'app_correct':
  tests/test_project/app_correct/migrations/0003_a_column.py
    - Add field column to a
Linting for 'app_correct':
(app_correct, 0003_a_column)... ERR
        NOT NULL constraint on columns

The migration linter detected that this migration is not backward compatible.
- If you keep the migration, you will want to fix the issue or ignore the migration.
- By default, the newly created migration file will be deleted.
Do you want to keep the migration? [y/N] n
Deleted tests/test_project/app_correct/migrations/0003_a_column.py
```

The sequence is: the generator writes the migration, the linter checks it, the linter reports the problem, and then the tool asks what to do. The default answer is no, and the default action is to delete the file that was just created.

Deleting the file is the right default, and the reasoning is in the readme: if you keep the migration, you will want to fix the issue or ignore the migration. Keeping a migration the tool has just told you is broken, without either fixing it or marking it as deliberate, is the one outcome that is always wrong. So the tool removes the evidence of the mistake and makes you put it back deliberately.

The prompt is a confirmation rather than a hard failure, because the tool cannot know whether this particular not null column is a problem in your project. A column that is added to a table nobody reads is harmless. A column added to a table the old code writes to is not. The linter can tell you the pattern, and you have to know the schema.

And this behaviour can be made the default of the generator, through a Django setting named for the override. Which is a small, well-judged design decision: the check is available at the moment of creation, and a team that wants it enforced everywhere can have it without editing anyone's habits.

## Where the checks come from, and the rule list

The readme points at two documentation pages for the details, and they are the ones to read before trusting the tool on a real project.

The first is the list of incompatibilities the linter analyses. That page is the specification of what counts as a backward incompatible operation, and it is the page that determines what the tool will and will not catch.

That distinction matters more than it might seem. A linter that flags everything is a linter that gets switched off, and a linter whose rules are published is one you can reason about. If the tool flags a migration you consider safe, you can look up the rule and decide whether to ignore the migration or to argue that the rule should change. If the tool misses something you consider dangerous, the same page tells you it was never going to be caught, and that is a gap you have to close yourself.

The second page is the usage documentation, which covers the advanced options and the configuration. The readme's summary of the configuration is that it is optional, and the detail lives in that page, so the shape is: sensible defaults, and a documented set of knobs.

The third documentation area is the internals folder, described as implementation details. That directory in a repository like this is usually where the interesting design decisions are written down, and the fact that the project keeps it means the maintainer expects people to want to understand how the analysis works rather than only what it reports.

There is also a separate page for the migration generator command, since the generator integration has enough options to deserve its own document.

And then there is the writing around the project, which is unusually good for a linter of this size. There is a blog post on keeping Django database migrations backward compatible, a second one on Django and its default values, which is the deeper topic behind the not null problem, and a section collecting places where other people have written about the linter, including a template for Django projects that lists it among the linters they recommend.

The related project is also worth noting: a separate package for testing migrations, including their order and best practices. That one answers a different question, which is whether the migrations are correct, rather than whether they are safe to deploy.

## The packaging, and the test dependencies that explain the scope

The package manifest is short, and two entries in it explain the scope of the project.

The runtime dependencies are the framework itself and a small library for locating the application's directory. The second one sounds trivial and is not: a migration linter has to find the migration files of every installed application, and the framework's own helpers do not do that cleanly, so the project pulls in a library that does.

The classifiers list the framework versions supported, and they include the current release and the two that follow it, which is a forward looking choice for a tool whose whole job is to help you upgrade. The Python versions listed run from 3.11 to 3.15, and the package requires 3.11 or newer.

The keywords are the tool's sales pitch in seven words, and they include backward and compatibility, which tells you the problem is framed as compatibility rather than as safety.

The test dependencies are the most interesting part of the manifest, and they explain what the linter is checked against.

There is a test runner, and there are three database drivers: a MySQL client, a PostgreSQL driver, and a package whose name says exactly what it is, one that adds default values to fields. That last one is the key dependency, because the not null rule is only meaningful if the linter understands what happens to the default value of a field when a migration adds it.

That is the subtlety the whole tool turns on, and it is why the rule is not just a string match on a field being made non null. A field that gains a default value at the same time is safe, because the old code will read the default and write it back. A field that becomes non null with no default is not, because the old code has no value to supply. Detecting that difference requires the linter to look at the model and at the field's default, and the third party package provides the ability to add a default to a field in a way the linter can see.

The blog post the project links on default values is the companion to that dependency, and it is the background reading for the rule that appears most often in the example output.

The build itself is standard, the package is marked as typed with a marker file included in the package data, and there is a separate optional group for the tests, so a user installing the linter in production does not pull in three database drivers.

## The repository, and how the project is run

The repository layout is small, and the fact that there is a manage file at the root is the detail that explains the whole test setup.

The source lives in a directory with a modern layout, the tests in their own, and there is a management script at the root of the repository, which is what lets the test suite exercise the linter through the same entry point a user has. A linter that is tested by calling its internals is tested on the wrong surface.

There is a documentation directory with the pages the readme links to, including the internals one. There is a change log, a manifest for the source distribution, a configuration for the pre-commit hooks, a configuration for tox, and a configuration for the dependency update tool, which means the library dependencies are kept current automatically.

The change log has its own entry in the project metadata, pointing at the file in the repository, so the release notes are written by hand in the repository rather than generated at release time. For a tool where the rules change between versions, a hand-written change log is the right choice, because the interesting entry is not which version changed but which rule was added or removed, and that is a decision somebody made on purpose.

The manifest also lists tox among the test dependencies, which is the signal that the linter is tested against several environments rather than one, which for a tool that must work across framework versions is the only way to know.

And the provenance of the project is in the readme's last lines. It was created originally at a company, and is now maintained by an individual. The authors field in the manifest lists both.

That combination, a tool that started inside one organisation's engineering practice and is now maintained by one person, explains a lot about the design. The rules are the ones that organisation needed, the ignore mechanism exists because their own migrations were sometimes deliberately incompatible, and the release cadence follows one maintainer's time.

## Conclusion

django-migration-linter fits a team that deploys the application and the database separately, which is the normal case for a service that has more than one instance of an older version running during a rollout. It does not fit a team whose migrations are always applied to an empty database, since the whole class of problem it detects is about the old code still running. Verify first which incompatibilities the current version detects, since the list is documented and changes between releases, that the ignore mechanism is used deliberately rather than as a way to silence the check, and that you run the linter against the migrations that will actually be applied, because a migration that is correct in isolation can still be incompatible in sequence.

## FAQ

### What does the django-migration-linter do?

It inspects the migrations of a Django project and flags the ones that are backward incompatible, meaning they would break a deployment still running an older version of the code. The examples in the readme are adding a not null column, dropping a table, and renaming a table.

### How do I run the Django migration linter?

Install the package, add it to the installed applications in the settings, and run the lintmigrations management command. The output lists each migration with a status of fine, erroneous, warning, or ignored, followed by a summary with the counts of each category.

### Can the linter check a migration as it is generated?

Yes. The migration generator takes a lint option, and the linter then checks the file it has just written, reports the problem, and asks whether to keep it. By default the newly created file is deleted, and this behaviour can be made the default of the generator through a Django setting.

### How does the linter decide that a not null column is dangerous?

The distinction is whether the field gains a default value at the same time, because the old code can then read the default and write it back. A field that becomes non null with no default leaves the old code with no value to supply. The test dependencies include a package that adds default values to fields, which is what lets the linter see that difference.

### What does it mean to ignore a migration?

It marks a migration as a deliberate exception, and the linter reports it as ignored rather than as an error, with the ignored count in the summary. The documentation lists every incompatibility the linter analyses, so a migration can be ignored once you have confirmed that the rule does not apply to your schema.

## Sources

- [David-Wobrock/django-migration-linter on GitHub](https://github.com/David-Wobrock/django-migration-linter)
- [License: Apache-2.0](https://github.com/David-Wobrock/django-migration-linter/blob/main/LICENSE)
- [Project website](https://pypi.python.org/pypi/django-migration-linter/)
- [README](https://github.com/David-Wobrock/django-migration-linter/blob/main/README.md)
- [Releases](https://github.com/David-Wobrock/django-migration-linter/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/david-wobrock-django-migration-linter
