In Django forms, how do you set a ChoiceField's choices per request, and why does cleaned_data return strings rather than integers?
answer
- class-level versus instance fields
- base_fields is deep-copied
- callable choices
- to_python returns str
- TypedChoiceField coerce
basics
~10 sSet Django ChoiceField choices per request by passing a callable or assigning self.fields['name'].choices in the form's init, never on the class. ChoiceField cleans to strings; use TypedChoiceField with coerce=int for integers.
solid answer
~40 sEach Django form instance deep-copies the class's `base_fields` into `self.fields`, so assigning `self.fields['topics'].choices = ...` inside `__init__` affects only that instance; setting a field's `choices` also updates its widget. Mutating `base_fields` or a module-level field instead leaks one request's options into every other request. For choices computed at display time you can pass a callable, which Django evaluates each time the form is initialised and again when rendering. On cleaning, `ChoiceField.to_python()` turns the value into a `str` and checks it against the choice keys compared as strings, so `choices=[(1, 'Weekly')]` cleans to `'1'`, and a later `== 1` test silently fails. `TypedChoiceField(coerce=int, empty_value=None)` validates the string against the choices and then returns `1`.
code
python · 17 linesfrom django import forms
ALL_TOPICS = [(1, 'Python'), (2, 'Databases'), (3, 'Premium deep dives')]
class TopicPreferenceForm(forms.Form):
topic = forms.TypedChoiceField(coerce=int, empty_value=None, required=False)
def __init__(self, *args, is_premium=False, **kwargs):
super().__init__(*args, **kwargs)
allowed = ALL_TOPICS if is_premium else ALL_TOPICS[:2]
self.fields['topic'].choices = [('', 'No preference'), *allowed]
f = TopicPreferenceForm(data={'topic': '3'}, is_premium=False)
f.is_valid() # False: '3' is not an available choice
f = TopicPreferenceForm(data={'topic': '2'})
f.is_valid() # True
f.cleaned_data['topic'] # 2, an intgo deeper
Recall that a ChoiceField cleans to a string, and that TypedChoiceField with coerce converts it to another type.
Explain the base_fields deep copy that makes self.fields safe to edit in init, and when a callable for choices is enough.
Show you would spot a class-level choices mutation leaking data between users and a silent integer-versus-string comparison bug.
Discuss where choice lists should live, in code, enums or tables, and how that choice affects migrations and translations.
## Why ChoiceField returns strings Everything a browser submits is text. `ChoiceField` keeps that honest: 1. `to_python()` converts the submitted value to `str`, or to `''` if it is empty. 2. `validate()` runs the `required` check, then `valid_value()`, which accepts the value if it equals a choice key **or** equals the key converted to a string, looking inside option groups too. 3. The cleaned value stays a string. So a newsletter topic field declared with integer keys, `choices=[(1, 'Python'), (2, 'Databases')]`, validates `'1'` happily and hands `'1'` to your view. Code that compares `cleaned_data['topic'] == 1` or looks it up in a dict keyed by integers fails without an error. ## TypedChoiceField `TypedChoiceField` adds two arguments: - **`coerce`**: a function applied after the string has passed the choices check, such as `int`. If it raises `ValueError`, `TypeError` or `ValidationError`, the field reports `invalid_choice`. - **`empty_value`**: what an empty optional value cleans to, `''` by default; `None` is the usual choice with integer keys. `TypedMultipleChoiceField` does the same for lists. For choices that are database rows, a queryset-backed model choice field is the right tool rather than hand-built tuples. ## Setting choices per request The list of topics may depend on the subscriber's plan, the language, or rows in a table. Three approaches, in order of preference: | Approach | How | Evaluated | |---|---|---| | Callable | `forms.ChoiceField(choices=get_topics)` | each time the form is initialised, and during rendering | | `__init__` override | accept a kwarg, then `self.fields['topic'].choices = ...` | once per form instance | | Class mutation | `NewsletterForm.base_fields['topic'].choices = ...` | never do this | - **Callables** suit choices that depend only on global state such as a table's current rows. The function takes no arguments, so it cannot see the request. - **The `__init__` override** is the standard answer when choices depend on the user or request: pop your own keyword argument before calling `super().__init__()`, then assign. It is safe because `BaseForm.__init__` builds `self.fields` as a **deep copy** of the class's `base_fields`, and a `ChoiceField`'s copy includes its own choices and widget. The `choices` setter also pushes the normalised list onto the widget, so the rendered `<select>` matches validation. - **Mutating the class** changes `base_fields`, which is shared by every future instance in that process. One subscriber's premium topics become visible to everyone until the next restart. ## Accepted choice formats - An iterable of `(value, label)` pairs, with nested `(group_label, [pairs])` for option groups. - An enumeration type such as a `TextChoices` or `IntegerChoices` subclass, passed directly. - A mapping of value to label. - A callable returning any of the above. Passing an enumeration class without `.choices`, and passing a mapping, are accepted since Django 5.0. ## Bugs that show up in review - **Type drift**: `if form.cleaned_data['topic'] == 1` never matches a plain `ChoiceField`; either compare strings or switch to `TypedChoiceField(coerce=int)`. - **Widget-only edits**: assigning `self.fields['topic'].widget.choices` changes what renders but not what validates, so users see options the field then rejects. Assign the field's `choices`, which updates both. - **Class-level mutation**: any assignment through `base_fields`, or through a field object shared at module level, is a cross-request leak. - **Placeholder options**: a leading `('', 'No preference')` is fine on an optional field; on a required one it deliberately triggers the required error. ## What interviewers listen for That cleaned choices are strings and `TypedChoiceField` fixes it, that per-request choices go on `self.fields` in `__init__`, and why touching the class leaks state between requests.
- In Django forms, why is mutating Form.base_fields inside a view dangerous?`base_fields` is class state shared by every future instance in the process. Each instance deep-copies it into `self.fields`, so edits there are private, but edits to `base_fields` leak into all later requests until the process restarts.
- When does Django evaluate a callable passed as a forms.ChoiceField's choices?Each time the form is initialised, and again when the field is rendered. That keeps choices current with the database without restarting, but the callable takes no arguments, so request-specific choices still belong in `__init__`.
saying these in an interview costs you the question
- ChoiceField returns the choice key in its original Python type.
- Assigning choices on the form class per request is safe.
- Changing self.fields in __init__ affects every form instance.
- TypedChoiceField skips checking the value against the choices.
- A choices callable receives the current request as an argument.