Why doesn't django have a model.ChoiceField

I use a lot of CharField with a “choices” attribute:

class Biota(models.TextChoices):
	FLORA = "flora", _("Flora")
	FAUNA = "fauna", _("Fauna")
	FUNGI = "fungi", _("Fungi")
	
class Species(models.Model):
	biota = models.CharField(
		blank=True,
		default="",
		choices=Biota.choices,
	)

Since those are charFields, they return strings. But I’d like to have models.TextChoices values instead, to avoid the need to “cast” values manually:

biota: Biota = Biota(species.biota) if species.biota else None

There are multiple packages that add this feature to django:

I’m trying to understand why this is not a default django feature. I haven’t found any discussions about this on the forum or the tracker.

Is there a good reason not to use that pattern ?

There are a lot of features that don’t suit the “django batteries included”, or are just too niche to get merged into Django. Actually a lot of 3rd party packages were never merged to Django core even when they get a lot of traction of the community (like django-rest-framework).

From now on, this is mine, and only mine opinion on this matter:

I personally hate the python’s Enum class, it’s clunky, error prone, hard to use, easy to shoot yourself on the foot. Generate errors for invalid value (this makes impossible to safely remove a legacy enum).
I don’t like any of the current solutions for this problem. On a project that I work, I use a different solution, using the typing.Literal to deliberate the DB values with conjunction with the typing.Annotated to provide the label values. With this approach, I get a great auto-complete and “type-safety” (with linters such as mypy with django-stubs-ext).
The approach I take is like:

from typing import Annotated, Literal

# note: I don't use gettext, but it probably might be easy to addapt or it works out of a box for it)
BiotaEnum = (
  Annotated[Literal["flora"], "Flora"]
  | Annotated[Literal["fauna"], "Fauna"]
  | Annotated[Literal["fungi"], "Fungi"]
  | Annotated[Literal[""], "N/A"] 
)

class Species(models.Model):
  biota: models.CharField[BiotaEnum, BiotaEnum] = literal_as_char_field(
    BiotaEnum,
    blank=True,
    default="",
  )

# somewhere else
# ............................
from typing import get_args, Protocol, Unpack


def literal_as_choices(literal) -> list[tuple[str | None, str]]:
    """Returns an Annotated[Literal["SOMETHING"], "Something label"] as a django choices like object.
    If the type is not annotated with a proper label, an error will be raised"""
    choices = []
    for annotation in get_args(literal):
        if isinstance(annotation, str):
            # A single annotated type yields first the Literal, then the string inside the literal
            # in that we don't want this value, it was already added
            continue

        metadata = getattr(annotation, "__metadata__", ())
        # Supports Union of Annotated types, using `|`

        if not metadata:
            # Supports a single Annotated type
            metadata = getattr(literal, "__metadata__", ())

        try:
            label = metadata[0]  # type: ignore
        except IndexError as e:
            raise TypeError(f"Missing Annotated label for {annotation=}") from e
        assert isinstance(label, str), f"Expected a `str` instance for label of {annotation=} don't use gettext"

        db_value = annotation
        while not isinstance(db_value, str | None):
            if db_value is None.__class__:
                # accepts `Annotated[None]` as well as `Annotated[Literal[None]]` the latter is discouraged by ruff
                db_value = None
                break
            db_value = get_args(db_value)[0]

        choices.append((db_value, label))
    return choices


class DjangoValidatorProtocol(Protocol):
    def __call__(self, value: Any) -> None: ...


class CharFieldKwargs(TypedDict):
    verbose_name: NotRequired[str]
    help_text: NotRequired[str]
    name: NotRequired[str]
    primary_key: NotRequired[bool]
    max_length: NotRequired[int]
    unique: NotRequired[bool]
    blank: NotRequired[bool]
    null: NotRequired[bool]
    db_index: NotRequired[bool]
    default: NotRequired[str | None]
    editable: NotRequired[bool]
    serialize: NotRequired[bool]
    choices: NotRequired[Sequence[tuple[Any, str]]]
    db_column: NotRequired[str]
    db_tablespace: NotRequired[str]
    auto_created: NotRequired[bool]
    validators: NotRequired[Sequence[DjangoValidatorProtocol]]
    error_messages: NotRequired[dict[str, str]]
    db_comment: NotRequired[str]


def literal_as_char_field(literal, **kwargs: Unpack[CharFieldKwargs]) -> models.CharField:
    """Returns a models.CharField from the given Literal type. The literal type must be annotated with the proper
    label for the end-user otherwise an error will be raised."""
    choices = literal_as_choices(literal)
    kwargs = {
        "choices": choices,
        "max_length": max(len(db_value or "") for db_value, _ in choices),
        **kwargs,
    }
    return models.CharField(**kwargs)

Feel free to use this code wherever you like. Maybe one day I will publish this into a django package.

I’ve been using this from about an year and the DX have been really great.
There are some limitations on the python language in the moment that would improve this examples, right now you can’t turn the literal_as_choices and literal_as_char_field generic, and you need to be really verbose at the model definition, repeating the BiomaEnum a few times, but it is what it is. I just feel happy for not using the python’s Enum module.

Hope that helps.

I’m also not a fan of this pattern for a different reason.

I “grew up” in an environment where the Database was King. A database is a company resource, not an application’s resource. The database should be as self-contained as reasonably practical, so as to not inhibit use of that database by a variety of applications.

As a result, our database designs require that “code tables” be used in all cases where a “TextChoices” option might be considered.

The primary reason for this is to ensure consistency is maintained across all uses of the database.

The database itself maintains the data integrity of the relationship, ensuring that only the proper values exist within the database.

I much prefer adding 20 additional tables to the database over having to ensure the internal consistency of 20 separate fields across “n” tables in all the code that may wish to use those fields. After all that’s what a relational database is designed to do.

(There are also numerous cases where doing this enhances the flexibility of the application, but that’s a side-point, not a driver.)

2 Likes

You may be interested in this ticket: #24342 (Add EnumField model/form fields) – Django

For most databases this is pretty straightforward, but for (at least) Postgres you need to handle the creation (and deletion) of enums which needs to happen before you can add them to a table definition. Not impossible though, just needs the operations writing and possibly a note for Postgres that you need to ensure it’s created first.