How to Disallow Auto-named Django Migrations

When you run Django’s manage.py makemigrations
, it will try to generate a name for the migration based upon its contents. For example, if you are adding a single field, it will call the migration 0002_mymodel_myfield.py
. However when your migration contains more than one step, it instead uses a simple ‘auto’ name with the current date + time, e.g. 0002_auto_20200113_1837.py
. You can provide the -n
/--name
argument to makemigrations
, but developers often forget this.
Naming things is a known hard problem in programming. Having migrations with these automatic names makes managing them harder: You can’t tell which is which without opening them, and you could still mix them up if they have similar names due to being generated on the same day.
This becomes painful when:
- rebasing branches
- digging through history
- deploying to production
In the worst case, running the wrong migration could lead to data loss!
It’s also all too easy to forget to fix the name and commit since Django doesn’t prompt you for a better name. We can guard against this with some automation!
Let’s look at three techniques to do so.
1. Overriding makemigrations
to require -n
/--name
This uses the same technique of overriding a built-in management command as I used in my post “Make Django Tests Always Rebuild the Database if It Exists”.
Add a new makemigrations
command to the “core” app in your project (e.g. myapp/management/commands/makemigrations.py
) with this content:
from django.core.management.base import CommandError
from django.core.management.commands.makemigrations import Command as BaseCommand
class Command(BaseCommand):
def handle(self, *app_labels, name, dry_run, merge, **options):
if name is None and not dry_run and not merge:
raise CommandError("Myproject customization: -n/--name is required.")
super().handle(
*app_labels,
name=name,
dry_run=dry_run,
merge=merge,
**options,
)
(Replace “Myproject” with the name of your project.)
We don’t require a name when either of two flags are used: --dry-run
(the check_changes
variable) or --merge
(merge
). These don’t create migration files so a name requirement would be bothersome.
After creating this, when we run makemigrations
we will see a message like this:
$ python manage.py makemigrations
Myproject customization: -n/--name is required.
Because this change only applies to makemigrations
, it automatically only affects new migrations, and not those in third party apps. Nice.
2. A Custom System Check
This is a custom system check that I’ve used on a few client projects.
To add it your project, you’ll first want to add it to a module inside one of your apps. I normally write add checks.py
in the project’s “core” app (whatever it’s called):
# myapp/checks.py
from fnmatch import fnmatch
from django.core.checks import Error
def check_migration_names(app_configs, **kwargs):
from django.db.migrations.loader import MigrationLoader
loader = MigrationLoader(None, ignore_no_migrations=True)
loader.load_disk()
errors = []
for (app_label, migration_name), _ in loader.disk_migrations.items():
if (app_label, migration_name) in IGNORED_BADLY_NAMED_MIGRATIONS:
continue
elif fnmatch(migration_name, "????_auto_*"):
errors.append(
Error(
f"Migration {app_label}.{migration_name} has an automatic name.",
hint=(
"Rename the migration to describe its contents, or if "
+ "it's from a third party app, add to "
+ "IGNORED_BADLY_NAMED_MIGRATIONS"
),
id="myapp.E001",
)
)
return errors
IGNORED_BADLY_NAMED_MIGRATIONS = {
# Use to ignore pre-existing auto-named migrations:
# ('myapp', '0002_auto_20200123_1257'),
}
Some notes on the code:
- We need to use an inner import for
MigrationLoader
, since it depends on all the Django having loaded all the apps, and we will import our check before then. - We tell the migration loader to load the names of all migrations from disk and iterate over them.
- We use the standard library
fnmatch
function to perform simple string matching on the filename. This is easier to read and write than using regular expressions. - We have
IGNORED_BADLY_NAMED_MIGRATIONS
at the bottom, a set of two-tuples like (app name, migration name). I’ve left a commented example of the expected data structure.
To run the check we need to register it in our app’s AppConfig.ready()
:
# myapp/apps.py
from django.apps import AppConfig
from django.core import checks
from myapp.checks import check_migration_names
class MyappConfig(AppConfig):
name = "myapp"
def ready(self):
checks.register(checks.Tags.compatibility)(check_migration_names)
…and ensure we use our AppConfig
in INSTALLED_APPS
:
INSTALLED_APPS = [
# ...
"myapp.apps.MyappConfig",
# ...
]
Running checks will highlight any problematic migration files:
$ python manage.py check
SystemCheckError: System check identified some issues:
ERRORS:
?: (myapp.E001) Migration myapp.0002_auto_20200123_1257 has an automatic name.
HINT: Rename the migration to describe its contents, or if it's from a third party app, add to IGNORED_BADLY_NAMED_MIGRATIONS
System check identified 1 issue (0 silenced).
Django also runs checks at the start of most manage.py
commands, and in the test runner.
If you’re adding this to a project with pre-existing auto-named migrations, you will see each as an error. You should add them to IGNORED_BADLY_NAMED_MIGRATIONS
, rather than renaming them. Django only knows migrations by name, so if you rename them, it will detect them as not applied and try apply them again - woops.
3. With a pre-commit Hook
If you're using pre-commit (and you should, it's really good!), you can also use a hook to ban auto-generated files with much less code:
- repo: local
hooks:
- id: no-auto-migrations
name: no auto-named migrations
entry: please provide a descriptive name for migrations
language: fail
files: .*/migrations/.*_auto_.*\.py$
exclude: |
(?x)^(
myapp/migrations/0002_auto_20200123_1257\.py
|myapp/migrations/0003_auto_20200123_1621\.py
)$
This uses the fail
pseudo-language to automatically fail any files matching that regex. Pretty neat!
The only downside of this approach is that you have to use a long regex in exclude
to skip pre-existing badly named migrations.
Learn more about pre-commit, particularly for Python projects, in my DX book.
One summary email a week, no spam, I pinky promise.
Related posts:
- How to Add Database Modifications Beyond Migrations to Your Django Project
- Moving to Django 3.0’s Field.choices Enumeration Types
- Common Issues Using Celery (And Other Task Queues)
Tags: django, pre-commit