When a team writes a custom Django field template, what must it keep so each input stays linked to its label, help text and errors?
answer
- the widget adds attributes itself
- ids ending _helptext and _error
- aria-describedby on the input
- fieldset and legend for groups
- help text is not escaped
basics
~10 sKeep rendering the widget with {{ field }}, labels with label_tag or legend_tag, errors with {{ field.errors }}, and give help text the id <auto_id>_helptext: Django's aria-describedby on the input points at those ids.
solid answer
~40 sDjango wires accessibility through the `BoundField`, not the template. When the widget renders via `{{ field }}`, Django adds `id`, `required`, `aria-invalid="true"` when the field has errors, and `aria-describedby` listing `<auto_id>_helptext` when there is help text and, since 5.2, `<auto_id>_error` when there are errors. The default error list renders as `<ul ... id="<auto_id>_error">`, but the help-text id exists only if the template writes it. So a custom template must keep `{{ field }}` rather than a hand-written `<input>`, use `{{ field.label_tag }}` so `for=` matches `id_for_label`, render `{{ field.errors }}` rather than bare messages, add `id="{{ field.auto_id }}_helptext"`, and, for `use_fieldset` widgets such as `RadioSelect`, wrap them in `<fieldset>` with `{{ field.legend_tag }}` and put `aria-describedby` on the fieldset.
code
python · 16 linesfrom django import forms
from django.test import SimpleTestCase
class DonationForm(forms.Form):
amount = forms.DecimalField(min_value=1, help_text="In euros.")
class DonationFieldA11yTests(SimpleTestCase):
def test_invalid_amount_is_described(self):
form = DonationForm(data={"amount": "0"})
html = str(form)
self.assertIn('aria-invalid="true"', html)
self.assertIn('aria-describedby="id_amount_helptext id_amount_error"', html)
self.assertIn('id="id_amount_helptext"', html)
self.assertIn('id="id_amount_error"', html)go deeper
Remember to render inputs with {{ field }} and labels with label_tag, so Django keeps the ids and attributes that link them.
Explain which attributes BoundField adds to the widget and which ids a field template must supply, including the help-text id and fieldset handling.
Review custom field templates for dropped ids and hand-written inputs, add tests on aria attributes, and keep untrusted data out of help_text rendered with |safe.
Make accessibility part of the form template contract, with tests and reviews that gate every design-system change to field markup.
## Where the accessibility wiring lives A Django form field reaches the browser as several pieces: the input, its label, its help text and its errors. Assistive technology needs them **linked**: the label to the input by `for`/`id`, and the help text and errors to the input by `aria-describedby`. Django builds most of that in Python, inside `BoundField`, and the templates only have to keep the hooks in place. When the widget is rendered through `{{ field }}` (which calls `BoundField.as_widget()`), Django adds to the input: - `id` from `auto_id` (default `id_<name>`); - `required` when the field is required and the form uses the attribute; - `aria-invalid="true"` when the field has errors (Django 5.0+); - `aria-describedby` built from `<auto_id>_helptext` when there is help text (5.0+) and `<auto_id>_error` when there are errors (5.2+) — unless the widget already sets `aria-describedby`, or the field is a `use_fieldset` group. ## Which ids the template is responsible for | id or attribute | who produces it | |---|---| | input `id`, `aria-invalid`, `aria-describedby` | the `BoundField`, when the template uses `{{ field }}` | | `<label for="...">` | `{{ field.label_tag }}`, using `id_for_label` | | `<ul class="errorlist" id="<auto_id>_error">` | `{{ field.errors }}` with the default error-list template (5.2+) | | `id="<auto_id>_helptext"` on the help text | **the field template itself** | | `aria-describedby` on a `<fieldset>` | **the field template**, using `{{ field.aria_describedby }}` | The last two rows are where custom templates break things: the input's `aria-describedby` still names `id_amount_helptext`, but nothing on the page carries that id. ## A checklist for a custom field template 1. **Render the widget with `{{ field }}`.** A hand-written `<input name="{{ field.html_name }}">` loses the value, `id`, `required`, `aria-invalid` and `aria-describedby`, and silently diverges from the widget class. 2. **Use `label_tag`** so `for` matches `id_for_label`, which for some widgets differs from `auto_id` (multi-input widgets point at a sub-widget or none). 3. **Group multi-input widgets.** When `field.use_fieldset` is true — `RadioSelect`, `CheckboxSelectMultiple`, `MultiWidget`, `SelectDateWidget` — wrap the widget in `<fieldset>` with `{{ field.legend_tag }}`, and put `aria-describedby="{{ field.aria_describedby }}"` on the fieldset, because Django does not add it to the inputs in that case. 4. **Give help text its id**: `id="{{ field.auto_id }}_helptext"`, guarded by `{% if field.auto_id %}`. 5. **Render `{{ field.errors }}`**, not a loop that prints messages in unlabelled markup; the default list carries the `_error` id. 6. **Keep hidden fields out of this template**; the form template places them. ```django {% if field.use_fieldset %} <fieldset{% if field.aria_describedby %} aria-describedby="{{ field.aria_describedby }}"{% endif %}> {% if field.label %}{{ field.legend_tag }}{% endif %} {% else %} {% if field.label %}{{ field.label_tag }}{% endif %} {% endif %} {% if field.help_text %}<p class="hint"{% if field.auto_id %} id="{{ field.auto_id }}_helptext"{% endif %}>{{ field.help_text }}</p>{% endif %} {{ field.errors }} {{ field }} {% if field.use_fieldset %}</fieldset>{% endif %} ``` ## A security note about help text Django's built-in field template outputs `{{ field.help_text|safe }}`, so help text is **not** escaped — it is treated as developer-written markup. That is fine for text declared in code, but never build `help_text` from user input or other untrusted data. A custom template that drops `|safe`, as above, escapes it instead, which is the safer default unless the help text needs markup. ## Verifying it - Render a bound, invalid form in a test and assert on the input's `aria-invalid` and `aria-describedby`, and on the presence of the ids they reference. - Check a `RadioSelect` field separately: its description belongs on the `<fieldset>`, not on each radio. - Remember that `{{ field.aria_describedby }}` (Django 5.2+) returns exactly the ids Django would use, so templates can reuse it instead of rebuilding the string.
- Why must a RadioSelect field's description go on the fieldset rather than on each radio?For widgets with `use_fieldset = True`, Django groups the inputs in a `<fieldset>` with a `<legend>` and does not add `aria-describedby` to each input. The field template puts `{{ field.aria_describedby }}` on the fieldset, so the help text and errors describe the group once.
- Is help text escaped in Django's default field template?No. The default template renders `{{ field.help_text|safe }}`, treating help text as trusted markup written by developers. Never build `help_text` from user-supplied data; a custom template can drop `|safe` to escape it.
saying these in an interview costs you the question
- a custom template can write <input> by hand and keep the same behaviour
- Django adds id attributes to help text wherever it is rendered
- aria-describedby belongs on each radio input in a RadioSelect
- Django escapes help_text, so it is safe to build from user input
- errors printed in a bare <p> loop keep their aria link