Filter concepts by levelShowing all levels.

Django · Section 13

Choices and Enumerations

Level
intermediate
Read
16 min
Concepts
2

Defining a fixed value set with plain tuples or the typed TextChoices/IntegerChoices enum subclasses, retrieving the human-readable label via get_<field>_display(), the database-representation and migration implications of a choices list, and recognizing when enforced transitions between values mean the field has outgrown a plain choices list into a real state machine.

What is true here

  1. TextChoices/IntegerChoices are Python Enum subclasses — MEMBER = value, "Label" — wrapping the exact same choices= list Django always accepted.
  2. The database only ever stores the raw value; get_<field>_display() (or an enum member's .label) is the separate, auto-generated label lookup.
  3. choices= alone adds no database-level constraint — a bulk .update() or raw SQL can still write an out-of-list value unless a CheckConstraint is added.
  4. Reordering (not just adding/removing) choices produces a new migration, even though no stored data changes.
  5. A choices field with rules about which transitions are legal, not just which values are valid, needs explicit workflow logic — not a bigger enum.

What you will be able to do

  • Define a choice set with TextChoices/IntegerChoices instead of a loose list of tuples
  • Retrieve a field's display label correctly, and know it is never stored in the database
  • Add a CheckConstraint when the database itself must reject an out-of-list value
  • Recognize when a choices field needs enforced transition logic instead of a bigger enum

Defining a choice set

Plain tuples vs the typed TextChoices/IntegerChoices enum subclasses.

Field choices, TextChoices, and IntegerChoices

coreintermediate

choices= restricts a field to a fixed set of values and renders as a dropdown in forms/admin. models.TextChoices and models.IntegerChoices are Python enum subclasses that define the set as named class attributes — MEMBER = value, "Label" — so the valid options live in one typed, importable place instead of a loose list of tuples.

Think of it as

A plain choices= list of tuples is a lookup table Django accepts as data. TextChoices/IntegerChoices turn that same table into a real Python Enum — every member becomes an attribute you can import and reference (Order.Status.PAID), not just a string you have to spell correctly. The database still only ever sees the raw value ("PAID", not the enum member) — the enum is a typed, autocomplete-friendly wrapper around exactly the same choices= list Django always supported.

python
class Order(models.Model):
    class Status(models.TextChoices):
        PENDING = "PENDING", "Pending"
        PAID = "PAID", "Paid"

    status = models.CharField(max_length=20, choices=Status, default=Status.PENDING)

What we're doing: Define an order status as a TextChoices enum and reference a specific status from application code without a bare string.

orders/models.pypython
class Order(models.Model):
    class Status(models.TextChoices):
        PENDING = "PENDING", "Pending"
        PAID = "PAID", "Paid"
        CANCELLED = "CANCELLED", "Cancelled"

    status = models.CharField(max_length=20, choices=Status, default=Status.PENDING)

    def mark_paid(self):
        self.status = Order.Status.PAID
        self.save(update_fields=["status"])
2
Nesting Status inside Order keeps the enum scoped to the model it belongs to — referenced elsewhere as Order.Status, not a loose module-level name.
8
Order.Status.PAID is a real Python object with autocomplete and a typo-proof reference — compare to the error-prone alternative of writing the bare string "PAID" everywhere it is checked or assigned.

Why this works: A bare choices= list of tuples still works, but every place that checks or sets a status has to spell the raw string correctly with no help from the editor or a type checker; TextChoices turns the same values into named, importable attributes, catching a typo'd status string at review time (or via a linter) instead of at runtime.

Comparing against the enum member instead of unwrapping .value in a raw SQL or serialization boundary

Wrong

python
# passing the enum member directly into raw SQL
cursor.execute("SELECT * FROM orders WHERE status = %s", [Order.Status.PAID])

Better

python
cursor.execute("SELECT * FROM orders WHERE status = %s", [Order.Status.PAID.value])

What you see: The raw SQL query silently returns zero rows, or a database driver raises a type-adaptation error, even though Order.objects.filter(status=Order.Status.PAID) works fine through the ORM.

Why: Django's own ORM knows how to unwrap a TextChoices/IntegerChoices member to its underlying value automatically when building a query, but raw SQL, a database driver, or a non-Django serializer does not — those boundaries need the member's .value explicitly, since the enum member itself is not the same object type the database driver expects.

Order.Status.PAID — one enum member, three facets

.value

"PAID" — what the database stores

.label

"Paid" — the display text

.name

"PAID" — the attribute name

  • .value — "PAID" — what the database stores
  • .label — "Paid" — the display text
  • .name — "PAID" — the attribute name

Defining a set of choices

Defining a set of choices
ApproachShape
Plain tupleschoices=[('FR', 'Freshman'), ('SO', 'Sophomore')]
TextChoicesclass YearInSchool(models.TextChoices): FRESHMAN = "FR", "Freshman"
IntegerChoicesclass Suit(models.IntegerChoices): DIAMOND = 1, "Diamond"
Callablechoices=get_currencies — evaluated at call time, useful for i18n/third-party lists

Together

python
class Order(models.Model):
    class Status(models.TextChoices):
        PENDING = "PENDING", "Pending"
        PAID = "PAID", "Paid"
        CANCELLED = "CANCELLED", "Cancelled"

    status = models.CharField(max_length=20, choices=Status, default=Status.PENDING)

Remember: TextChoices/IntegerChoices are Enum subclasses wrapping the same choices= list Django always accepted — reference members as Class.Enum.MEMBER for typed autocomplete, and only .value crosses into raw SQL or non-Django serialization.

See also: display and migrations · primary keys and field options · text and numeric fields

Advertisement

Display, storage, and outgrowing a choices field

What get_<field>_display() actually does, what the database enforces on its own, and when the rule needs a state machine instead.

Display labels, storage, and when choices outgrow a field

standardintermediate

A choices field stores only the raw value ("PAID") in the database, never the label ("Paid") — get_<field>_display() (or an enum member's .label) is how you retrieve the human-readable text. Reordering a choices list creates a new migration even though no data changes; a choices field that needs enforced transitions (PENDING can go to PAID but never back) has outgrown a plain choices field and needs explicit workflow logic instead.

Think of it as

choices= is a presentation-layer convenience on top of an ordinary column — the database schema for a CharField(choices=Status) is identical to a plain CharField, no CHECK constraint is added by choices alone, so the database will happily store a value that isn't in the list unless something else (application code, a CheckConstraint) enforces it. That is exactly why choices is right for "which of these fixed labels applies" but wrong for "which transitions between values are legal" — the moment the rule becomes about ORDER or PERMITTED MOVES between states rather than just a fixed label set, that is a state machine's job, not a field's.

python
order.get_status_display()   # "Paid" — auto-generated for any choices= field

What we're doing: Show a human-readable status in a template while confirming what is actually stored in the database.

orders/views.pypython
def order_detail(request, pk):
    order = Order.objects.get(pk=pk)
    label = order.get_status_display()
    return render(request, "orders/detail.html", {"order": order, "status_label": label})
3
get_status_display() is generated automatically by Django because status has choices= — no method named this actually needs to be written; it exists purely because of the choices= declaration.

Why this works: order.status stays the compact stored value ("PAID"), useful for filtering (Order.objects.filter(status="PAID")) and comparisons — get_status_display() is the separate, template-friendly path to the label, so the two never need to be kept in sync manually.

Assuming choices= blocks an invalid value from ever reaching the database

Wrong

python
# choices= alone, no CheckConstraint
status = models.CharField(max_length=20, choices=Status)
Order.objects.filter(pk=1).update(status="NOT_A_REAL_STATUS")   # succeeds

Better

python
class Meta:
    constraints = [
        models.CheckConstraint(
            check=models.Q(status__in=[c[0] for c in Status.choices]),
            name="order_status_valid",
        )
    ]

What you see: A bulk .update() or a raw SQL statement writes a status value that is not in the Status enum at all, and Django never complains — it only surfaces later as a broken get_status_display() call or a UI branch that doesn't know how to render the unexpected value.

Why: choices= is enforced by ModelForm/full_clean() validation, which a bulk .update(), raw SQL, or a direct INSERT never goes through — it is not a database constraint by itself. A CheckConstraint (or a foreign key to a real lookup table) is the only way to make the database itself reject an out-of-list value.

What choices= does and does not do

What choices= does and does not do
QuestionAnswer
Where is the label stored?Nowhere — only the value is in the database; the label is looked up at render time
Does the database enforce the value set?No — add a CheckConstraint separately for that, choices= is UI/validation-layer only
Does reordering choices need a migration?Yes — the field's choices order is part of its definition
When does a choices field outgrow itself?When some transitions between values must be forbidden — that needs a state machine, not a bigger enum

Together

python
order = Order.objects.get(pk=1)
order.status                        # "PAID" — the raw value
order.get_status_display()          # "Paid" — the label, looked up at call time
Order.Status.PAID.label             # "Paid" — same label, no instance needed

Remember: The database only ever stores the raw value — get_<field>_display() (or .label) is the label lookup; choices= is not a database constraint on its own, and enforced transitions between values (not just valid values) belong in explicit workflow logic, not a bigger choices list.

See also: text and integer choices · constraints and indexes · instance methods and domain logic

Advertisement