Django: Test for pending migrations

Always test your bike before using it to migrate.

This post is an adapted extract from my book Boost Your Django DX, available now.

Django requires every change to model fields and meta classes to be reflected in database migrations. This applies even to things that don’t typically affect the database, such as Field.choices. When iterating on code, it’s easy to make a model change and forget to update the migrations accordingly. If you don’t have any protection, you might even deploy code that crashes due to out-of-date migrations!

To protect against this, you can run the makemigrations command with a couple of flags:

$ ./manage.py makemigrations --check

The --check flag causes the command to fail (have a non-zero exit code) if any migrations are missing. (Before Django 4.2.9, you also need to add the --dry-run option, to prevent the command from writing migrations to disk.)

Update (2024-06-23): Dropped the --dry-run option, apart from the above mention.

Let’s look at an example project that is missing a migration.

The project has an Author model with a name field. The migrations create the name field with a max_length of 100 characters. But the model has since been updated to use a max_length of 200.

Run the above command, and it will fail with a report of the required migration:

$ ./manage.py makemigrations --check
Migrations for 'example':
  example/migrations/0002_alter_author_name.py
    - Alter field name on author

This command does the job, but it’s also another thing to remember. It’s not likely you’ll run it locally when you need to, and it needs another step in your CI system.

A convenient way to run this command is within a test. You can set up a test in your project’s “core” app to run the command and fail if it detects any missing migrations. Here’s the code:

from io import StringIO

from django.core.management import call_command
from django.test import TestCase


class PendingMigrationsTests(TestCase):
    def test_no_pending_migrations(self):
        # No migrations pending
        # See: https://adamj.eu/tech/2024/06/23/django-test-pending-migrations/
        out = StringIO()
        try:
            call_command(
                "makemigrations",
                "--check",
                stdout=out,
                stderr=StringIO(),
            )
        except SystemExit:  # pragma: no cover
            raise AssertionError("Pending migrations:\n" + out.getvalue()) from None

It should work to copy this test case as-is into your project.

Here’s how it works:

When you run this test, you’ll see the command output (colourized if your terminal supports it):

$ ./manage.py test
Found 1 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
F
======================================================================
FAIL: test_no_pending_migrations (example.tests.test_migrations.PendingMigrationsTests.test_no_pending_migrations)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/.../test_migrations.py", line 19, in test_no_pending_migrations
    raise AssertionError(
AssertionError: Pending migrations:
Migrations for 'example':
  example/migrations/0002_alter_author_name.py
    - Alter field name on author


----------------------------------------------------------------------
Ran 1 test in 0.129s

FAILED (failures=1)
Destroying test database for alias 'default'...

Add this test to your project and protect yourself against missing migrations.

Fin

This is a great defense that I add to every project I work on. I hope it serves you well,

—Adam


😸😸😸 Check out my new book on using GitHub effectively, Boost Your GitHub DX! 😸😸😸


Subscribe via RSS, Twitter, Mastodon, or email:

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

Related posts:

Tags: