How to Disallow Auto-named Django Migrations

2020-02-24 Migrating Pony

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:

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.

Update (2020-02-25): Originally this article just included my custom system check (#2). Thanks to wonderful feedback on Reddit and Twitter, I've included two more methods, both of which are shorter.

1. Overriding makemigrations to require -n/--name

Update (2020-02-25): Thanks to @toyg on reddit for pointing this one out.

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, **options):
        if options["name"] is None:
            raise CommandError(
                "Myproject customization: -n/--name is required."
            )
        super().handle(*app_labels, **options)

(Replace “Myproject” with the name of your project.)

Then running makemigrations will output a message like this:

$ python manage.py makemigrations
Myproject customization: -n/--name is required.

Because this only applies to makemigrations, it automatically only affects new migrations, and not those in third party apps. Nice.

2. A Custom System Check

Update (2020-02-25): Thanks to Nikita Sobolev, this check is available in the package `django-test-migrations` from version 0.2.0+. See "Testing migration names" in its documentation.

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:

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

Update (2020-02-25): Anthony Sottile, creator of pre-commit, pointed out this shorter technique on Twitter.

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.

Fin

I hope this helps you keep your projects’ code just a bit cleaner,

—Adam


Working on a Django project? Check out my book Speed Up Your Django Tests which covers loads of best practices so you can write faster, more accurate tests.


Subscribe via RSS, Twitter, or email:

One summary email a week, no spam, I pinky promise.

Related posts:

Tags: django