Skip to content

Indexes from Meta.indexes not restored when RenameField and AlterField (type or nullability change) occur in the same migration #499

@robberwick

Description

@robberwick

When a field is renamed via RenameField AND subsequently altered (type change or nullability change) via AlterField in the same migration, indexes defined in Meta.indexes that include the field are dropped but not restored. Depending on the specific scenario, a FieldDoesNotExist exception may also be raised.

This was identified during the work on #486 / #498 and is documented with @expectedFailure tests in that PR.

This bug was deliberately not addressed in #498 because the fix there targets the common case i.e. altering a field without renaming it. The rename + alter combination involves a different problem: a mismatch between the model state (where RenameField has already updated the field name) and the index metadata (where index.fields still references the old name). Fixing this requires changes to how _delete_indexes() resolves field names and how the restoration guard handles column renames, both of which are orthogonal to the Meta.indexes restoration logic added in #498. Bundling both fixes together would have broadened the scope and risk of that PR.

Root Cause

There are two separate failures at play:

1. _delete_indexes() raises FieldDoesNotExist

When AlterField calls _delete_indexes(), it iterates over model._meta.indexes and calls model._meta.get_field(field_name) for each field in the index:

# mssql/schema.py, _delete_indexes() for index in model._meta.indexes: columns = [model._meta.get_field(field).column for field in index.fields]

However, RenameField has already updated the model state — the field is now known by its new name (e.g., a_renamed), but index.fields still references the old name (e.g., a). The get_field('a') call raises FieldDoesNotExist.

2. Restoration phase is skipped for renamed columns

Even if the drop phase were to succeed, the restoration block in _alter_field is guarded by:

if (old_type != new_type or (old_field.null != new_field.null)) and ( old_field.column == new_field.column # column rename is handled separately above ):

When a field has been renamed, old_field.column != new_field.column, so the entire restoration block is skipped. The comment says "column rename is handled separately above", referring to the sp_rename code path — but that path only renames the column itself; it does not drop and recreate indexes that reference the column.

Repoduction steps:

Create a model with a Meta.indexes index, then run a migration that renames a field and alters it:

# Migration 1: Create model with index operations = [ migrations.CreateModel( name='MyModel', fields=[ ('id', models.AutoField(primary_key=True)), ('a', models.CharField(max_length=20)), ('b', models.CharField(max_length=20)), ], ), migrations.AddIndex( model_name='mymodel', index=models.Index(fields=['a', 'b'], name='idx_ab'), ), ] # Migration 2: Rename field and change its type operations = [ migrations.RenameField( model_name='mymodel', old_name='a', new_name='a_renamed', ), migrations.AlterField( model_name='mymodel', name='a_renamed', field=models.CharField(max_length=40), # type change ), ]

After running both migrations, the index idx_ab will be missing from the database.

The same issue occurs when changing nullability instead of type:

migrations.AlterField( model_name='mymodel', name='a_renamed', field=models.CharField(max_length=20, null=True), # nullability change ),

Existing Tests

The following @expectedFailure tests in testapp/tests/test_indexes.py (added in #498) exercise this bug:

  • test_index_from_meta_indexes_retained_after_rename_and_type_change
  • test_index_from_meta_indexes_retained_after_rename_and_nullability_change

Workaround

Split the rename and alter into separate migrations so that the rename is fully committed before the alter runs. This avoids the stale field name in model state and ensures old_field.column == new_field.column during the alter.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions