Search code examples
pythondjangopostgresqldjango-modelsdjango-migrations

Django constraints when removing columns in manual migrations


Django silently removes constraints when removing columns, then arbitrarily chooses to include them in migrations.

I've encountered an odd bug(?) in Django in our production code. Dropping a column manually in migrations without removing its constraints leads to Django being unaware those constraints have been removed, and auto-generating incorrect migrations.

Here's a short example.

The migrations for this schema:

class M():
    a = models.IntegerField(default=1)
    b = models.IntegerField(default=2)
    class Meta:
        constraints = [
            UniqueConstraint(
                name="uniq_ab_1", fields=["a", "b"]
            )
        ]

creates the constraint uniq_ab_1 is Postgres as expected (verified using the \d+ command on the table).

However, this manual migration will remove the constraint, due to one of its member columns being deleted; this is just standard Postgres behavior:

        migrations.RemoveField(
            model_name="m",
            name="a",
        ),
        migrations.AddField(
            model_name="m",
            name="a",
            field=models.IntegerField(default=6),
        ),

This migration runs just fine. I can even modify the field of M again and run a further migration. However, using \d+ reveals that the uniq_ab_1 constraint is gone from the database.

The only way I found out about this behavior was by renaming the constraint to uniq_ab_2, then auto-generating migrations and getting the error:

django.db.utils.ProgrammingError: constraint "uniq_ab_1" of relation … does not exist.

In other words, on a rename, Django became aware a rename was happening and tried to remove the constraint from the database, in spite of it being gone for a few migrations.

This behavior is rather unexpected. I'd assume Django would either:

  1. Notice the code model differs from the database schema in the original migration (when the constraint gets inadvertently removed) and fail.
  2. Notice that the constraint is missing in the next migration (e.g. when adding an arbitrary field to M) and try to add it back again.

As it stands, it seems like this ghost constraint is observed by some migration operations and not others.

Is this a known bug in Django? Is there a way to guard against this behavior? Is this perfectly normal and I am doing something wrong?


Solution

  • The question asserts several misconceptions about Django.

    Django generates migrations in python manage.py makemigrations by comparing the model definition and the model schema built from the existing migrations (i.e. code-first behavior), not by comparing the model definition and the database schema (as it's generally not reproducible).

    Addressing the misconceptions

    Django silently removes constraints when removing columns

    No, Django doesn't do that. The constraint is automatically dropped by PostgreSQL.
    https://postgrespro.com/docs/postgresql/9.6/sql-altertable

    You can check what Django does with python manage.py sqlmigrate.

    [Django] arbitrarily chooses to include [constraints] in migrations

    No, Django always considers the constraints when generating migrations.

    In other words, on a rename, Django become aware a rename was happening and tried to remove the constraint from the database, in spite of it being gone for a few migrations.

    Django isn't aware a rename is happening. Django simply removes the previous one (in the model schema built from existing migrations) and adds the new one (in the model definition).

    This behavior is rather unexpected. I'd assume Django would either:

    • Notice the code model differs from the database schema in the original migration (when the constraint gets inadvertently removed) and fail.
    • Notice that the constraint is missing in the next migration (e.g. when adding an arbitrary field to M) and try to add it back again.

    Django does check that the model definition has no problem at the start of python manage.py migrate, but does not compare that against the database schema — especially not on each step of the operations in a migration.

    When you create a manual migration, the above is your responsibility.

    Is this a known bug in Django?

    As explained above, this is not a bug and is caused by the wrongly planned manual migration.

    While it is possible for Django to help guard against this wrong manual operation (shown below), there are simply too many ways that a user can mess up a manual migration for Django to reasonably cover.

    Guarding against this behaviour

    Is there a way to guard against this behavior?

    You can patch BaseDatabaseSchemaEditor.remove_field to block removing fields that have unique constraints in the model definition:

    def _patch_remove_field():
        from itertools import chain
        from django.core import checks
        from django.db.backends.base.schema import BaseDatabaseSchemaEditor
        from django.db.models.constraints import UniqueConstraint
    
        old_remove_field = BaseDatabaseSchemaEditor.remove_field
    
        def remove_field(self, model, field):
            field_names = set(chain.from_iterable(
                (*constraint.fields, *constraint.include)
                for constraint in model._meta.constraints if isinstance(constraint, UniqueConstraint)
            ))
            if field.name in field_names:
                raise ValueError(checks.Error(
                    "Cannot remove field '%s' as '%s' refers to it" % (field.name, 'constraints'),
                    obj=model,
                    id='models.E012',
                ))
            old_remove_field(self, model, field)
    
        BaseDatabaseSchemaEditor.remove_field = remove_field
    

    Correct migrations

    Is this perfectly normal and I am doing something wrong?

    Yes. Here are the correct migrations.

    Generated migration

    python manage.py makemigrations preserves the unique constraint, but doesn't have the same behaviour (updating existing rows to be a = 6).

            migrations.AlterField(
                model_name='m',
                name='a',
                field=models.IntegerField(default=6),
            ),
    

    Manual migration

    You can get Django to partially help by replacing field a with c, generating a migration and then reverting back to a in both the model definition and the generated migration.

            migrations.RemoveConstraint(
                model_name='m',
                name='uniq_ab_1',
            ),
            migrations.RemoveField(
                model_name='m',
                name='a',
            ),
            migrations.AddField(
                model_name='m',
                name='a',
                field=models.IntegerField(default=6),
            ),
            migrations.AddConstraint(
                model_name='m',
                constraint=models.UniqueConstraint(fields=('a', 'b'), name='uniq_ab_1'),
            ),