In a Django form, why is a field with disabled=True safer than a readonly widget attribute for a value subscribers must not change?
answer
- what the browser submits
- what the server trusts
- cleaning reads initial instead
- initial needed on POST too
- has_changed() stays False
basics
~20 sA Django field with disabled=True ignores whatever is submitted and cleans the form's initial value, so a tampered POST cannot change it. A readonly attribute is only a browser hint; the submitted value is validated and used.
solid answer
~40 s`attrs={'readonly': True}` stops typing in the browser, but the value is still submitted and Django validates and returns it like any other, so a crafted POST can change a referral code. `disabled=True` renders the HTML `disabled` attribute and, more importantly, changes cleaning: the field cleans `bf.initial` instead of the submitted data, so tampered input is ignored and `has_changed()` is always False for it. The catch is that the value now comes from `initial`, so the view must pass the same `initial` when it binds the POST; `PreferencesForm(request.POST)` without it leaves a required disabled field with nothing to clean and raises "This field is required." Often the simplest option is to leave such a field out of the form and show it as text.
code
python · 18 linesfrom types import SimpleNamespace
from django import forms
class PreferencesForm(forms.Form):
referral_code = forms.CharField(disabled=True)
frequency = forms.ChoiceField(choices=[('weekly', 'Weekly'), ('monthly', 'Monthly')])
def build_form(sub, data=None):
initial = {'referral_code': sub.referral_code, 'frequency': sub.frequency}
return PreferencesForm(data=data, initial=initial)
sub = SimpleNamespace(referral_code='ANA-2026', frequency='weekly')
form = build_form(sub, data={'referral_code': 'HACKED', 'frequency': 'monthly'})
form.is_valid() # True
form.cleaned_data['referral_code'] # 'ANA-2026', the tampered value is ignored
broken = PreferencesForm(data={'frequency': 'monthly'}) # no initial on POST
broken.is_valid() # False: referral_code is requiredgo deeper
Recall that readonly only affects the browser, while disabled=True makes Django ignore submitted data for that field.
Explain that a disabled field cleans bf.initial, never appears in changed_data, and renders the disabled HTML attribute.
Show you would catch the missing-initial POST bug, write a tampering test, and prefer leaving immutable values out of the form entirely.
Position this as a trust-boundary rule: anything the client sends is input, so immutable data must come from the server, whatever the markup says.
## Two ways to lock a field A newsletter preferences page shows the subscriber's **referral code** next to fields they may edit, such as delivery frequency. The code must not change. Django offers two tempting ways to lock it, and they are not equivalent: | | `widget=TextInput(attrs={'readonly': True})` | `forms.CharField(disabled=True)` | |---|---|---| | Browser behaviour | cannot type; value still submitted | greyed out; browsers do not submit it | | What Django cleans | the submitted value | the form's initial value | | Tampered POST | accepted if it validates | ignored | | `has_changed()` | compares submission with initial | always False | | Works on select and checkbox | no, HTML ignores `readonly` there | yes | ## How Django implements disabled - The bound field adds `disabled` to the widget's attributes when it renders. - During cleaning, Django passes `bf.initial` to the field's `clean()` instead of `bf.data`, so whatever arrived in `request.POST` for that name is never looked at. - `Field.bound_data()` also returns the initial value, so a redisplayed form after a validation error shows the original code, not the tampered one. - `Field.has_changed()` returns False, so `changed_data` never lists the field. The documentation states the contract directly: even if a user tampers with the submitted value, it is ignored in favour of the value from the form's initial data. ## The production bug: initial must be there on POST too Because the value now comes from `initial`, the view has to supply it on **both** requests: 1. GET builds `PreferencesForm(initial={'referral_code': sub.referral_code, 'frequency': sub.frequency})` and the page looks right. 2. POST builds `PreferencesForm(request.POST)` with no `initial`. 3. Cleaning the disabled field now sees `None`; a required `CharField` turns it into `''` and raises "This field is required." An optional one quietly cleans to `''`, and code that writes `cleaned_data` back to the subscriber wipes the stored code. The fix is to build the form in one helper that always passes the same `initial` (or, for model-backed forms, the same `instance`), so GET and POST agree. Tests that post a tampered value and assert the stored code is unchanged catch both mistakes. ## Proving the lock in a test A lock is only as good as the test that tries to break it: 1. Build the form through the same helper the view uses, with `data` that changes the locked value. 2. Assert that `is_valid()` is True and that `cleaned_data['referral_code']` equals the stored code. 3. Assert that `'referral_code'` is not in `form.changed_data`, so audit logging stays quiet. 4. Post through the test client with the tampered value, reload the subscriber, and check nothing was written. The third assertion also documents intent. A later refactor that swaps `disabled=True` for a `readonly` attribute fails it at once, because a readonly field compares the tampered submission with `initial` and reports a change. ## Choosing between the options - **Display only**: if the subscriber never edits the value and the view never needs it back, render it as plain text in the template and leave it out of the form. There is nothing to tamper with. - **Show inside the form, value needed in cleaned_data**: `disabled=True` with a reliable `initial`. - **Read-only look for usability only**: `readonly` is fine as a hint, but treat the submitted value as untrusted and re-validate it or overwrite it from the database. - **Never** rely on a hidden input for a value that must not change; it is as editable as any other field. ## What interviewers listen for The distinction between a browser hint and server behaviour, the fact that disabled fields clean `initial`, and the matching-initial bug in the POST branch. Senior candidates add that the safest locked field is often the one that is not in the form at all.
- Does a disabled field in a Django form appear in form.changed_data after a POST that tampers with it?No. `Field.has_changed()` returns False for disabled fields, because the value used for cleaning and redisplay is always the initial one. Audit logic built on `changed_data` therefore never records a change to that field.
- In Django, why is a hidden input a poor way to carry a value the user must not change?A `HiddenInput` is only invisible; its value is submitted and cleaned like any other field, so anyone can edit it with developer tools or a crafted request. Use `disabled=True` with server-side `initial`, or keep the value out of the form and read it from the database.
saying these in an interview costs you the question
- A readonly attribute stops Django from accepting a changed value.
- Disabled fields are simply dropped from cleaned_data.
- A disabled field keeps working when the POST branch omits initial.
- A hidden input is a safe place for a value users must not edit.
- HTML readonly works the same on select and checkbox controls.