Introduction
Working with Django model fields with restricted choices can be tricky. If you're not careful, you may find that new migrations are being produced for models that aren't changing. This is a quick discussion on what causes this issue and how to address it.
The Problem
Consider the example Django provides in the choices docs.
# demo.models.py
from django.db import models
class Student(models.Model):
FRESHMAN = "FR"
SOPHOMORE = "SO"
JUNIOR = "JR"
SENIOR = "SR"
GRADUATE = "GR"
YEAR_IN_SCHOOL_CHOICES = {
FRESHMAN: "Freshman",
SOPHOMORE: "Sophomore",
JUNIOR: "Junior",
SENIOR: "Senior",
GRADUATE: "Graduate",
}
year_in_school = models.CharField(
max_length=2,
choices=YEAR_IN_SCHOOL_CHOICES,
default=FRESHMAN,
)
If we run makemigrations
, We'll see that the choices themselves live in the migrations file.
# shell
python manage.py makemigrations
Migrations for 'demo':
demo/migrations/0001_initial.py
+ Create model Student
# demo.migrations.0001_initial.py
...
operations = [
migrations.CreateModel(
name='Student',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('year_in_school', models.CharField(choices=[('FR', 'Freshman'), ('SO', 'Sophomore'), ('JR', 'Junior'), ('SR', 'Senior'), ('GR', 'Graduate')], default='FR', max_length=2)),
],
),
]
If we run makemigrations
a second time, we'd expect nothing to happen since nothing has changed.
# shell
python manage.py makemigrations
No changes detected
But what if I move the iterable outside the model class scope and run makemigrations
?
# demo.models.py
from django.db import models
FRESHMAN = "FR"
SOPHOMORE = "SO"
JUNIOR = "JR"
SENIOR = "SR"
GRADUATE = "GR"
YEAR_IN_SCHOOL_CHOICES = {
FRESHMAN: "Freshman",
SOPHOMORE: "Sophomore",
JUNIOR: "Junior",
SENIOR: "Senior",
GRADUATE: "Graduate",
}
class Student(models.Model):
year_in_school = models.CharField(
max_length=2,
choices=YEAR_IN_SCHOOL_CHOICES,
default=FRESHMAN,
)
# shell
python manage.py makemigrations
No changes detected
As we probably expected, nothing occurred because the choices hadn't changed either. But what if the choices we need live in a set?
# demo.models.py
YEAR_IN_SCHOOL_CHOICES_SET = set(YEAR_IN_SCHOOL_CHOICES.items())
class Student(models.Model):
year_in_school = models.CharField(
max_length=2,
choices=YEAR_IN_SCHOOL_CHOICES_SET,
default=FRESHMAN,
)
# shell
python manage.py makemigrations
Migrations for 'demo':
demo/migrations/0002_alter_student_year_in_school.py
~ Alter field year_in_school on student
# demo.migrations.0002_alter_student_year_in_school.py
...
operations = [
migrations.AlterField(
model_name='student',
name='year_in_school',
field=models.CharField(choices=[('FR', 'Freshman'), ('JR', 'Junior'), ('SO', 'Sophomore'), ('SR', 'Senior'), ('GR', 'Graduate')], default='FR', max_length=2),
),
]
We now have a fresh migration even though the choices themselves haven't changed. Any subsequent call to makemigrations
will likely have the same effect. It's clear that this situation could become problematic if not addressed.
The reason is that sets aren't ordered. You'll notice that while the above examples use the same data, the choices produced with a set have different ordering. Django is detecting the field's choices as having changed. As a result, new migrations are being produced to reflect the "changes."
The Solution
Simply enough, we just need to order the set before it's set as the model field's choices attribute. This can be accomplished using the Python built-in sorted()
function. That way, Django will see the same choices in the same order each time makemigrations
is run.
# demo.models.py
...
YEAR_IN_SCHOOL_CHOICES_SET = sorted(set(YEAR_IN_SCHOOL_CHOICES.items()))
class Student(models.Model):
year_in_school = models.CharField(
max_length=2,
choices=YEAR_IN_SCHOOL_CHOICES_SET,
default=FRESHMAN,
)
Because the most recent migration contained choices likely in a different order than what sorted()
will produce, we'll need to run makemigrations
twice to see this take effect.
# shell
python manage.py makemigrations
Migrations for 'demo':
demo/migrations/0003_alter_student_year_in_school.py
~ Alter field year_in_school on student
python manage.py makemigrations
No changes detected
With sorting established, any subsequent call to makemigrations
will not be affected by the choices on our field.
In Practice
Ok, so make sure your choices are ordered and maybe just stay away from sets altogether with model fields. That seems trivial. However, not everything we use with our models is built locally.
One particular example is with time zones. For Django 4 and earlier, the pytz
library was supported (zoneinfo
actually becomes the default implementation in Django 4 but remains supported until Django 5 removes support entirely). With pytz
, a list is produced when using all_timezones
to generate time zone choices. Lists have ordering and therefore all_timezones
could be used directly as the model field's choices attribute without a problem.
With zoneinfo
in Django 5, a set is created when using the equivalent available_timezones()
.
# shell
>>> import zoneinfo
>>> type(zoneinfo.available_timezones())
<class 'set'>
If we use the set produced by available_timezones
, we'll run into the same problem demonstrated above. The choices produced will be ordered inconsistently. Therefore, every time we run makemigrations
a fresh migration will be produced even without changes to the model.
Fortunately, we already know the fix. We simply need to apply ordering and we're good to go, right? There's an additional step because we're not getting a set of tuples but rather a set of the actual time zone values. Django expects an iterable with two elements for each choice so we'll need to handle that as well.
# demo.models.py
...
timezones = sorted(zoneinfo.available_timezones())
TIMEZONE_CHOICES = tuple(zip(timezones, timezones))
Let's test this with a simple UserProfile
model containing a single field "timezone."
# demo.models.py
...
class UserProfile(models.Model):
timezone = models.CharField(choices=TIMEZONE_CHOICES)
When we run makemigrations
a fourth time, we'll get a fresh set for the new model. Subsequent calls to makemigrations
shouldn't produce anything unless other changes are made.
# demo.models.py
python manage.py makemigrations
Migrations for 'demo':
demo/migrations/0004_userprofile.py
+ Create model UserProfile
python manage.py makemigrations
No changes detected
We can now use zoneinfo
for time zone choices without producing unnecessary migrations.
Final Thoughts
Seemingly benign things like migrating from pytz
to zoneinfo
for producing time zone choices for Django model fields can have unexpected outcomes. Furthermore, you might not even know you're creating unnecessary migrations because you're likely running makemigrations
due to changes elsewhere in the project and wouldn't be surprised if migrations are produced.
This discussion demonstrated a common situation where external data affected migrations being produced that weren't desirable. Be careful to ensure that choices are correctly ordered or you might fall into this trap.