None

Considerations for Django Model Field Choices

A discussion on how to appropriately handle Django model field choices so that migrations aren't produced unnecessarily.

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.

Details
Published
May 28, 2025
Next
April 15, 2024

How to Use Time Zones with Django

A discussion on how to set up your Django project to handle timezones.