Form Rendering & Widgets

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Django 6.1 (6.1.1, released 2026-09-02; 6.1.2 is scheduled for 2026-10-06), Python 3.12-3.14 and PostgreSQL 15 or newer, all checked on 2026-10-03; where the 5.2 LTS (5.2.17) behaves differently the text says so. The third-party part uses django-crispy-forms 2.7 (2026-07-29) with crispy-bootstrap5 2026.9 (2026-09-02), both declared compatible with Django 6.1 on PyPI. Every example was run in a scratch project on SQLite with the Django test client and the system check framework; none needs PostgreSQL or Redis, and nothing on this page is untested. A form that validates correctly is only half of the job: the other half is the HTML that the browser receives. This page covers that half for the Bookshelf application (app name catalog): the rendering API that turns forms, fields and widgets into markup through templates, how to change it at project, form, field and widget level, how to write widgets of your own, how a widget ships its CSS and JavaScript through Media, and when a layout library such as crispy-forms is worth adding. The form classes themselves, their fields and the GET/POST flow are on Forms; validation is on Form Validation & Validators; ModelForm and formsets are on Model Forms & Formsets.

In a hurry: what goes where

Need Rule of thumb Section

Render a whole form

{{ form }} (same as form.as_div); it is the default output style since Django 5.0. as_p, as_ul, as_table still exist.

Output styles: as_div, as_p, as_ul, as_table

Render one field with label, help, errors

{{ form.title.as_field_group }} (Django 5.0+), not four hand-written lines.

Field groups (Django 5.0)

Change the markup of every form in the project

FORM_RENDERER = "django.forms.renderers.TemplatesSetting", "django.forms" in INSTALLED_APPS, then override django/forms/*.html in your own templates directory.

Renderers and FORM_RENDERER

Change the markup of one form, field or render call

Form.template_name, Field.template_name, form.render("my/template.html").

Overriding at form, field and call level

Add a CSS class to every label or field wrapper

A BoundField subclass, selected with bound_field_class (Django 5.2+) on the renderer, the form or the field.

Customising BoundField and bound_field_class (Django 5.2)

Change what an <input> looks like

Override the widget template (django/forms/widgets/input.html) or give a widget its own template_name.

Widgets

Write a new widget

Subclass Widget, set template_name, extend get_context(), and read the POST data in value_from_datadict() when it is not a single value.

Writing a custom widget

Ship a widget’s CSS and JavaScript

An inner Media class on the widget; print {{ form.media }} once in the page head.

Form assets: the Media class

Get Bootstrap markup without writing templates

django-crispy-forms 2 plus crispy-bootstrap5: the |crispy filter or {% crispy form %} with a FormHelper.

django-crispy-forms 2 and crispy-bootstrap5

From form to HTML: the rendering pipeline

Since Django 4.0 forms are rendered by the template engine instead of by Python string concatenation. That single change is what makes everything on this page possible: every level of the output is a template that can be replaced.

flowchart TD A["form printed in a page template"] --> B["Form.render() picks Form.template_name"] B --> R["Renderer: FORM_RENDERER or Form.default_renderer"] R --> C["django/forms/div.html
(form template)"] C --> D["BoundField.as_field_group()
per field"] D --> E["django/forms/field.html
(field group template)"] E --> F["BoundField: label_tag, help_text, errors"] E --> G["BoundField.as_widget()"] G --> H["Widget.render() uses Widget.template_name"] H --> I["django/forms/widgets/input.html
(widget template)"] I --> J["attrs.html"]

Three template levels exist, and each has its own name attribute and its own context:

Level Default template Context Override with

Form

django/forms/div.html (BaseRenderer.form_template_name)

form, fields, hidden_fields, errors

Form.template_name, or form_template_name on the renderer

Field group

django/forms/field.html (BaseRenderer.field_template_name)

field (a BoundField)

Field.template_name, or field_template_name on the renderer

Widget

django/forms/widgets/<name>.html

widget, a dict with name, value, attrs, is_hidden, template_name and widget-specific keys

Widget.template_name, or replacing the built-in file

Formsets have the same structure one level up (BaseRenderer.formset_template_name, default django/forms/formsets/div.html), and so does the list of errors (django/forms/errors/list/). Rendering is plain synchronous template rendering: it runs in the view or in the template engine, never touches the database (a ModelChoiceField evaluates its queryset when the <select> is rendered, which is a database hit; see Query Optimization & Fetch Modes), and in an async view such a form must be rendered through sync_to_async(), because the queryset evaluation raises SynchronousOnlyOperation there.

Output styles: as_div, as_p, as_ul, as_table

A form can be printed in four ready-made styles, and {{ form }} simply calls Form.render() with the form’s default template.

from django import forms


class SearchForm(forms.Form):
    q = forms.CharField(label="Search")
    in_stock = forms.BooleanField(required=False)
>>> form = SearchForm()
>>> print(form.as_div())      # same as {{ form }} and str(form) in Django 5.0+
<div>
    <label for="id_q">Search:</label>
    <input type="text" name="q" required id="id_q">
</div>
<div>
    <label for="id_in_stock">In stock:</label>
    <input type="checkbox" name="in_stock" id="id_in_stock">
</div>
>>> form.as_p()    # <p> per field, label and widget separated by a line break
>>> form.as_ul()   # <li> per field; you supply the surrounding <ul>
>>> form.as_table()  # <tr> per field; you supply the surrounding <table>

Source: Output styles

as_div() is the style to prefer: it wraps related inputs (radio groups, checkbox lists, multi-widgets) in a <fieldset> with a <legend>, which is what screen readers need, and it adds the aria-describedby and aria-invalid attributes that connect a field to its help text and error list. as_ul() and as_table() do not include the surrounding <ul> or <table> tags.

Writing the form by hand when you must

Do not do this unless the layout truly cannot be produced with templates, but the loop is short:

<form method="post">
  {% csrf_token %}
  {{ form.non_field_errors }}
  {% for hidden in form.hidden_fields %}{{ hidden }}{% endfor %}
  {% for field in form.visible_fields %}
    <div class="mb-3">
      {{ field.label_tag }}
      {{ field }}
      {% if field.help_text %}<small id="{{ field.auto_id }}_helptext">{{ field.help_text }}</small>{% endif %}
      {{ field.errors }}
    </div>
  {% endfor %}
  <button type="submit">Save</button>
</form>

A hand-written loop silently loses what the built-in templates do: the aria-describedby wiring (the attribute on the <input> names ids that your hand-written help text and errors must really carry), the <fieldset> and <legend> for multi-input widgets, and the rendering of hidden-field errors. That is the argument for field groups, next.

Field groups (Django 5.0)

A field group is one field plus everything around it: label (or legend), help text, errors and widget. Before 5.0 a template that wanted a custom layout had to write all four pieces for every field. Django 5.0 added BoundField.as_field_group(), which renders the django/forms/field.html template, so a layout only decides where each group sits:

<form method="post" novalidate>
  {% csrf_token %}
  {{ form.non_field_errors }}
  {{ form.title.as_field_group }}
  <div class="row">
    <div class="col">{{ form.genre.as_field_group }}</div>
    <div class="col">{{ form.published.as_field_group }}</div>
  </div>
  {{ form.format.as_field_group }}
  {{ form.rating.as_field_group }}
  {{ form.summary.as_field_group }}
  {% for hidden in form.hidden_fields %}{{ hidden }}{% endfor %}
  <button type="submit">Save</button>
</form>

The default field.html is a few lines; knowing them explains every attribute the output carries:

{% 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 %}<div class="helptext"{% if field.auto_id %} id="{{ field.auto_id }}_helptext"{% endif %}>{{ field.help_text|safe }}</div>{% endif %}
{{ field.errors }}
{{ field }}{% if field.use_fieldset %}</fieldset>{% endif %}

Two details matter in practice. field.help_text|safe means help text is trusted HTML, so never put user input in a help_text; and use_fieldset is a property of the widget (Widget.use_fieldset, added in 4.1), true for RadioSelect, CheckboxSelectMultiple, MultiWidget, SplitDateTimeWidget and SelectDateWidget, which is why those fields get a <legend> instead of a <label>.

BoundField, the object templates work with

form.title in a template is a BoundField: the field bound to the form’s data, errors and rendering. Its attributes and methods are what every field template uses.

Member What it gives you

field, form, name, html_name

The Field instance, the form, the python name and the HTML name (with the form prefix).

auto_id, id_for_label

The generated id (id_title) and the id a <label for> must point at.

label, help_text, errors, value()

The label text, the help text, the ErrorList and the current value.

label_tag(), legend_tag()

The label as <label> or <legend> (legend_tag is new in 4.1 together with the tag argument of label_tag()).

as_widget(), as_field_group(), as_hidden()

The widget only, the whole group, and the hidden-input form.

css_classes()

The CSS classes for the row: Form.error_css_class, Form.required_css_class and any extra_classes.

widget_type

Lower-cased widget class name without Input: text, select, radioselect; handy for CSS hooks.

use_fieldset, aria_describedby

Whether the field needs a <fieldset>, and the ids of help text and errors (aria_describedby is new in 5.2).

subwidgets

The inputs of a multi-input widget, for rendering radio buttons one by one.

is_hidden, field.required, field.disabled

Branch on these in templates instead of re-deriving them.

Renderers and FORM_RENDERER

The renderer finds the templates. FORM_RENDERER (a dotted path) selects the project-wide default; the default value is "django.forms.renderers.DjangoTemplates".

Renderer Behaviour

django.forms.renderers.DjangoTemplates

Uses its own standalone Django template engine, unrelated to your TEMPLATES setting. Finds Django’s built-in form templates first, then the templates/ directory of installed apps. It never reads your project DIRS and does not apply your context processors.

django.forms.renderers.Jinja2

The same, with a Jinja2 backend and templates in a jinja2/ directory. Every form and widget in the project and its third-party apps must have a Jinja2 template, which rules out the admin (its widgets use Django template tags).

django.forms.renderers.TemplatesSetting

Looks templates up through whatever TEMPLATES configures, so form templates behave like any other template of the project and can be overridden from DIRS. It needs the built-in templates to be findable: "django.forms" in INSTALLED_APPS with at least one engine that has APP_DIRS=True, or the built-in directory added to DIRS.

The setup that makes the rest of this page work is three lines of settings plus a templates directory:

# config/settings.py
INSTALLED_APPS = [
    # ...
    "catalog",
    "django.forms",  # lets TemplatesSetting find Django's own form templates
]

TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [BASE_DIR / "templates"],
        "APP_DIRS": True,
        "OPTIONS": {"context_processors": [
            "django.template.context_processors.request",
            "django.contrib.auth.context_processors.auth",
            "django.contrib.messages.context_processors.messages",
        ]},
    },
]

FORM_RENDERER = "django.forms.renderers.TemplatesSetting"

The classic mistake is overriding django/forms/field.html while leaving FORM_RENDERER at its default: the file is ignored, without any error, because the default renderer’s engine never looks in DIRS. When an override seems to do nothing, check the renderer first. The renderer instance is created once per process and cached (get_default_renderer()), so changing the setting needs a restart in development too, except in tests where override_settings clears the cache for you.

Project-wide override of the field group template

With TemplatesSetting active, this file in templates/django/forms/field.html replaces the field group template for every form in the project, including admin forms:

<div class="field field--{{ field.widget_type }}{% if field.errors %} field--invalid{% endif %}">
  {% 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 %}<div class="helptext" id="{{ field.auto_id }}_helptext">{{ field.help_text|safe }}</div>{% endif %}
  {{ field.errors }}
  {{ field }}
  {% if field.use_fieldset %}</fieldset>{% endif %}
</div>

A BookForm for the catalogue (title, genre, format as radio buttons, publication date, rating, summary) rendered with this template produces, for a title that failed validation:

<div class="field field--text field--invalid">
    <label for="id_title">Title:</label>
  <div class="helptext" id="id_title_helptext">As printed on the cover.</div>
  <ul class="errorlist" id="id_title_error"><li>This field is required.</li></ul>
  <input type="text" name="title" maxlength="200" required aria-invalid="true"
         aria-describedby="id_title_helptext id_title_error" id="id_title">
</div>

The aria-invalid and aria-describedby attributes come from the widget context, not from the template; the id id_title_helptext is the one the template must keep, which is why replacing field.html carelessly breaks accessibility. The test that pins the behaviour:

from django.test import TestCase

from .forms import BookForm


class RenderingTests(TestCase):
    def test_form_renders_with_project_field_template(self):
        html = BookForm().render()
        self.assertIn('class="field field--text"', html)
        self.assertIn('class="field field--radioselect"', html)
        self.assertIn("<fieldset", html)  # RadioSelect uses a fieldset
        self.assertIn('id="id_title_helptext"', html)

Overriding at form, field and call level

Templates can also be chosen without touching the project defaults, at three narrower scopes.

Per form class. Set template_name (a string; since 4.1 it is otherwise a property that defers to the renderer):

{# catalog/templates/catalog/forms/cards.html #}
{{ errors }}
{% for field in form.visible_fields %}
  <section class="card">{{ field.as_field_group }}</section>
{% endfor %}
{% for field in form.hidden_fields %}{{ field }}{% endfor %}
class ReviewForm(forms.Form):
    template_name = "catalog/forms/cards.html"
    rating = forms.IntegerField(min_value=1, max_value=5)
    body = forms.CharField(widget=forms.Textarea)

Now {{ form }}, str(form) and form.render() all use the card layout, and form.as_div() still gives the stock style because it always names its own template (Form.template_name_div, similarly template_name_p, template_name_ul, template_name_table).

Per call. render() takes a template name, so one page can use a different layout for the same form:

>>> form.render("catalog/forms/cards.html")  # in Python code

The Django template language cannot call a method with an argument, so inside a template use {% include "catalog/forms/cards.html" %}, which sees form from the context, or call form.render(…​) in the view and put the resulting string in the context.

Per field. Field(template_name=…​) replaces the field group template for that one field, and the template receives the same field variable:

class SearchBox(forms.Form):
    q = forms.CharField(template_name="catalog/forms/field_inline.html")
{# catalog/templates/catalog/forms/field_inline.html #}
<p class="inline">{{ field.label_tag }} {{ field }}</p>

With a different renderer. A form instance accepts renderer= in its constructor, and a class can set default_renderer. A custom renderer class changes the defaults of all three levels at once:

from django.forms.renderers import TemplatesSetting


class ParagraphRenderer(TemplatesSetting):
    form_template_name = "django/forms/p.html"        # forms as <p> blocks
    formset_template_name = "django/forms/formsets/p.html"


class LegacyForm(forms.Form):
    default_renderer = ParagraphRenderer()
    isbn = forms.CharField()

The class-level renderer wins over FORM_RENDERER, and a renderer= argument given to a form instance wins over both. In the scratch project LegacyForm().render() produced paragraphs, whatever the project renderer is:

>>> print(LegacyForm().render())
<p>
    <label for="id_isbn">Isbn:</label>
    <input type="text" name="isbn" required id="id_isbn">
  </p>

Customising BoundField and bound_field_class (Django 5.2)

Everything in a field template that is not a widget goes through the BoundField. Before Django 5.2 the only way to use your own subclass was to override Field.get_bound_field() on every field class. Django 5.2 added a bound_field_class attribute at three levels, from broad to narrow; the narrowest one set wins:

Level Where

Project

bound_field_class on the renderer class (BaseRenderer.bound_field_class)

Form

Form.bound_field_class

Field

Field.bound_field_class, or the bound_field_class argument of the field constructor

A project-wide use is adding a state class to every field wrapper, the kind of rule that otherwise gets duplicated in every template:

from django import forms


class CustomBoundField(forms.BoundField):
    """Add an invalid/valid CSS class to every field of the form."""

    def css_classes(self, extra_classes=None):
        result = super().css_classes(extra_classes)
        if self.form.is_bound:
            state = "is-invalid" if self.errors else "is-valid"
            result = f"{result} {state}".strip()
        return result


class BookForm(forms.Form):
    bound_field_class = CustomBoundField

    title = forms.CharField(max_length=200, help_text="As printed on the cover.")
    # ...

css_classes() is the method the default div.html calls for the row wrapper, so the state class lands on the <div> around each field group. Bound with an empty title, the first row becomes <div class="is-invalid">; an unbound form gets no state class at all. The test:

def test_bound_field_class(self):
    form = BookForm({"title": ""})
    self.assertIn("is-invalid", form["title"].css_classes())
    self.assertNotIn("is-", BookForm()["title"].css_classes())

The precedence rules were verified in the scratch project: a renderer with bound_field_class = Tagged applied to a form’s fields, and a field created with bound_field_class=forms.BoundField overrode it for that single field; passing renderer= to a form instance applied the renderer’s class to that instance only.

class TaggedRenderer(TemplatesSetting):
    bound_field_class = Tagged            # every form that uses this renderer


class F(forms.Form):
    default_renderer = TaggedRenderer()
    a = forms.CharField()                                         # Tagged
    b = forms.CharField(bound_field_class=forms.BoundField)       # plain BoundField

Customise a BoundField for presentation logic that depends on the field’s state (classes, ids, extra context). Leave widget attrs (class, placeholder, autocomplete) to the widget, and keep validation out of both.

Widgets

A widget is the part of a field that knows how to draw an HTML input and how to read its value back out of the submitted data. The field decides validation and Python type; the widget decides markup. They are separate on purpose: DateField(widget=forms.DateInput(attrs={"type": "date"})) keeps the same parsing and uses a different input.

published = forms.DateField(widget=forms.DateInput(attrs={"type": "date"}))
summary = forms.CharField(widget=forms.Textarea(attrs={"rows": 3}), required=False)
format = forms.ChoiceField(
    choices=[("paper", "Paperback"), ("ebook", "E-book")],
    widget=forms.RadioSelect,
)

For a ModelForm the same choices go in Meta.widgets, see Model Forms & Formsets.

Built-in widgets at a glance

Widget Template (under django/forms/widgets/) Notes

TextInput, NumberInput, EmailInput, URLInput, PasswordInput, HiddenInput, DateInput, DateTimeInput, TimeInput

text.html, number.html, email.html, url.html, password.html, hidden.html, date.html, datetime.html, time.html

All extend Input and render through input.html; PasswordInput does not re-render the value unless render_value=True.

Textarea

textarea.html

Default rows and cols attributes are set; override with attrs.

CheckboxInput

checkbox.html

check_test decides when it is checked.

Select, NullBooleanSelect, SelectMultiple

select.html, select_option.html

Select extends ChoiceWidget; options render with option_template_name.

RadioSelect, CheckboxSelectMultiple

radio.html, checkbox_select.html

Several <input>`s, so `use_fieldset is true and the field gets a <legend>.

FileInput, ClearableFileInput

file.html, clearable_file_input.html

Need enctype="multipart/form-data" on the form (needs_multipart_form); see File Uploads & Storage.

MultiWidget, SplitDateTimeWidget, SelectDateWidget

multiwidget.html, splitdatetime.html, select_date.html

Several inputs combined into one value; use_fieldset is true.

ColorInput, SearchInput, TelInput

color.html, search.html, tel.html

Input subclasses with a fixed input_type.

Source: Widgets

Overriding the markup of a built-in widget

Because each widget names a template, replacing the built-in file changes every widget of that type. With TemplatesSetting, putting templates/django/forms/widgets/input.html in the project is enough. Do this to add a class that all inputs of a design system need:

<input type="{{ widget.type }}" name="{{ widget.name }}"{% if widget.value != None %} value="{{ widget.value|stringformat:'s' }}"{% endif %} class="form-control"{% include "django/forms/widgets/attrs.html" %}>

A widget’s attrs dictionary is the lighter tool when the change is for one field, and the class key in it is appended by attrs.html. If both the template and attrs set class, the browser sees two class attributes and uses the first, so pick one place.

Writing a custom widget

A custom widget is a class plus, usually, a template. The star rating of a book review is a good example: an <input type="range"> with an <output> that shows the stars.

# catalog/widgets.py
from django import forms
from django.forms import Script, Stylesheet


class RatingWidget(forms.Widget):
    """A 1-5 star rating rendered as a range input plus an <output>."""

    template_name = "catalog/widgets/rating.html"
    input_type = "range"

    class Media:
        css = {"all": [Stylesheet("catalog/rating.css", media="all")]}
        js = [Script("catalog/rating.js", defer=True)]

    def __init__(self, attrs=None):
        defaults = {"min": 1, "max": 5, "step": 1}
        super().__init__({**defaults, **(attrs or {})})

    def get_context(self, name, value, attrs):
        context = super().get_context(name, value, attrs)
        context["widget"]["type"] = self.input_type
        context["widget"]["stars"] = "★" * int(value or 0)
        return context
{# catalog/templates/catalog/widgets/rating.html #}
<span class="rating">
  {% include "django/forms/widgets/input.html" %}
  <output for="{{ widget.attrs.id }}">{{ widget.stars }}</output>
</span>

The pieces and the contract:

  • template_name is looked up through the renderer, like every form template.

  • __init__ takes attrs and passes a merged dictionary to the base class; user attributes win over the defaults.

  • get_context(name, value, attrs) returns the context dictionary. Call the base implementation, then add keys under context["widget"]; the template receives it as widget.

  • Reusing input.html with include inherits the standard name, value and attrs handling, including the aria-* attributes added by the form machinery.

  • format_value(value) (optional) converts the Python value into the string shown in the input.

  • value_from_datadict(data, files, name) (optional) reads the submitted data. The default reads data.get(name), which is right for any single-input widget.

  • value_omitted_from_data(data, files, name) tells a model form whether to fall back to the model default; it matters for widgets whose absence in the POST means something (checkboxes).

  • needs_multipart_form, use_fieldset, is_localized, supports_microseconds and is_hidden are the class-level switches (is_hidden is derived from input_type).

Using it is no different from a built-in widget, and the Media it declares reaches the page through the form:

rating = forms.IntegerField(min_value=1, max_value=5, widget=RatingWidget)
>>> BookForm()["rating"].as_widget()  # abbreviated
<span class="rating">
  <input type="range" name="rating" min="1" max="5" step="1" required id="id_rating">
  <output for="id_rating"></output>
</span>

A widget with several inputs: MultiWidget and MultiValueField

When one value needs several inputs (a reading time in hours and minutes), pair a MultiWidget with a MultiValueField. The widget splits a value for display (decompress); the field joins the cleaned parts (compress).

class ReadingTimeWidget(forms.MultiWidget):
    """Hours and minutes as two number inputs, compressed to minutes."""

    def __init__(self, attrs=None):
        widgets = [
            forms.NumberInput(attrs={"min": 0, "placeholder": "h"}),
            forms.NumberInput(attrs={"min": 0, "max": 59, "placeholder": "min"}),
        ]
        super().__init__(widgets, attrs)

    def decompress(self, value):
        if value is None:
            return [None, None]
        return divmod(int(value), 60)


class ReadingTimeField(forms.MultiValueField):
    widget = ReadingTimeWidget

    def __init__(self, **kwargs):
        fields = (forms.IntegerField(min_value=0), forms.IntegerField(min_value=0, max_value=59))
        super().__init__(fields, require_all_fields=True, **kwargs)

    def compress(self, data_list):
        hours, minutes = data_list
        return hours * 60 + minutes

The inputs are named reading_time_0 and reading_time_1, and the field cleans to a single integer, which the test below confirms through the view:

def test_view(self):
    r = self.client.post("/books/new/", {
        "title": "Dune", "genre": "sf", "format": "paper", "published": "1965-08-01",
        "rating": "5", "reading_time_0": "12", "reading_time_1": "30",
    })
    self.assertContains(r, "750 minutes, rating 5")

Because MultiWidget.use_fieldset is true, such a field renders inside a <fieldset> with a <legend> in both the default and the overridden field group template.

Form assets: the Media class

A widget that needs a stylesheet or a script declares it, and the form assembles the list for the whole page. There is no bundler involved: Media is just a list of paths, which Django prefixes with STATIC_URL, and the integration with collectstatic, hashed file names and CDNs is on Static Files & Front-End Assets.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>New book</title>
  {{ form.media }}
</head>
<body>
  <form method="post">
    {% csrf_token %}
    {{ form }}
    <button type="submit">Save</button>
  </form>
</body>
</html>

For the BookForm above, {{ form.media }} prints, because of the RatingWidget:

<link href="/static/catalog/rating.css" media="all" rel="stylesheet">
<script src="/static/catalog/rating.js" defer></script>
def test_media(self):
    media = str(BookForm().media)
    self.assertIn('<link href="/static/catalog/rating.css" media="all" rel="stylesheet">', media)
    self.assertIn('<script src="/static/catalog/rating.js" defer></script>', media)

What the Media options do

  • css is a dictionary from output medium (all, screen, print, or a comma-separated list) to a list of files.

  • js is a list of files. A path starting with /, http:// or https:// is used as-is; anything else is prefixed with STATIC_URL, falling back to MEDIA_URL when STATIC_URL is None. With ManifestStaticFilesStorage the hashed name is printed.

  • extend decides inheritance: by default a subclass inherits all assets of its parent; extend = False drops them, which the scratch project confirmed by rendering only b.js for a subclass declaring js = ["b.js"].

  • A form can declare its own inner Media (for layout CSS), and the form’s media property is the union of its own declaration and the media of every widget. media can also be a dynamic property that returns a forms.Media.

  • Subscripting filters one kind: form.media["css"] and form.media["js"]; Media objects can be added together.

Script and Stylesheet objects (5.2 and 6.1)

A bare path cannot carry HTML attributes, which is a problem for defer, async, type="module", crossorigin or integrity. Django 5.2 added Script for js; Django 6.1 added Stylesheet for css. Both take the path first and attributes as keyword arguments, are importable from django.forms, and compare by path and attributes, so the same file with different attributes is two assets:

from django.forms import Media, Script, Stylesheet

assets = Media(
    css={"print": [Stylesheet("catalog/print.css", media="print")]},
    js=[Script("catalog/app.js", type="module"), Script("catalog/legacy.js", defer=True)],
)
<link href="/static/catalog/print.css" media="print" rel="stylesheet">
<script src="/static/catalog/app.js" type="module"></script>
<script src="/static/catalog/legacy.js" defer></script>

On Django before 6.1, use string paths for CSS, or any object with an __html__ method (accepted by Media since 4.1) for custom link tags.

Merging order and MediaOrderConflictWarning

When several widgets contribute assets, Django merges the lists and tries to keep the relative order given by each list. If two lists demand opposite orders, it emits MediaOrderConflictWarning and falls back to an order that can include a duplicate. The scratch project reproduced it with the order jquery.js, a.js against a.js, jquery.js; the merged output repeated jquery.js, and with the warning turned into an error it raised:

import warnings

from django import forms
from django.forms.widgets import MediaOrderConflictWarning


class A(forms.TextInput):
    class Media:
        js = ["jquery.js", "a.js"]


class B(forms.TextInput):
    class Media:
        js = ["a.js", "jquery.js"]


with warnings.catch_warnings():
    warnings.simplefilter("error", MediaOrderConflictWarning)
    str(A().media + B().media)   # raises MediaOrderConflictWarning
def test_conflict_warning(self):
    with self.assertWarns(MediaOrderConflictWarning):
        str(A().media + B().media)

The fix is to list shared dependencies (the library first, then the plugins) in the same order in every widget, which the documentation’s jQuery.js, calendar.js, noConflict.js example demonstrates. Only the relative order inside each declaring class is guaranteed: do not rely on unrelated files from different widgets keeping the order in which you wrote them.

Source: Order of assets

django-crispy-forms 2 and crispy-bootstrap5

Django’s built-in rendering is enough for most forms, and with a project-level field.html (above) it can match a design system exactly. A layout library earns its place when forms need real layout decisions (rows and columns, fieldsets, inline buttons) written once in Python, or when a team wants framework markup without writing templates. The standard choice is django-crispy-forms; since 2.0 the template packs are separate packages, and for Bootstrap 5 it is crispy-bootstrap5.

pip install "django-crispy-forms>=2.7" "crispy-bootstrap5>=2026.9"
# config/settings.py
INSTALLED_APPS += ["crispy_forms", "crispy_bootstrap5"]   # both apps are required

CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5"
CRISPY_TEMPLATE_PACK = "bootstrap5"

crispy-forms does not include static files: load Bootstrap’s CSS and JavaScript yourself (a CDN link or your asset pipeline; see Bootstrap forms for the markup it expects).

The filter: zero configuration

{% load crispy_forms_tags %}
<form method="post">
  {% csrf_token %}
  {{ form|crispy }}
  <button type="submit" class="btn btn-primary">Save</button>
</form>

|crispy renders every field with Bootstrap 5 markup (mb-3 wrappers, form-label, form-control, form-select, form-check for radio buttons) and keeps the <fieldset>/<legend> for multi-input widgets. It renders the fields only: the <form> tag, {% csrf_token %} and the buttons stay in your template, and it does not print form.media.

The tag and FormHelper: layout in Python

{% crispy form %} renders the complete <form>, including the CSRF token and the form’s media, from a FormHelper attached to the form:

# catalog/crispy.py
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Column, Layout, Row, Submit

from .forms import BookForm


class BookCrispyForm(BookForm):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.helper = FormHelper()
        self.helper.form_method = "post"
        self.helper.layout = Layout(
            "title",
            Row(Column("genre"), Column("published")),
            "format",
            "rating",
            "summary",
            Submit("save", "Save book"),
        )
{% load crispy_forms_tags %}
{% crispy form %}

The scratch project served both variants and a test asserts the Bootstrap classes in the response:

class CrispyTests(TestCase):
    def test_crispy_helper_and_filter(self):
        r = self.client.get("/books/new/crispy/")
        self.assertContains(r, 'class="mb-3"')
        self.assertContains(r, 'class="row "')
        self.assertContains(r, 'class="btn btn-primary"')
        self.assertContains(r, "form-select")

Three pitfalls that the run showed:

  • Once a Layout exists, only the fields named in it are rendered. The reading_time field of BookForm was left out of the layout above and therefore silently missing from the page and from the POST data, so the form could never validate (the is_valid() check reported reading_time as the only error once the other fields were supplied). Name every field, or build the layout from self.fields.

  • {% crispy form %} already prints form.media; adding {{ form.media }} to the page head as well loads the widget’s assets twice.

  • The custom RatingWidget and ReadingTimeWidget rendered inside the Bootstrap wrappers and kept their Media, but the range input got the form-control class, which is the wrong Bootstrap class for a slider (form-range). Custom widgets need a crispy template-pack override or an attrs class; the markup that a pack produces is not aware of your widget’s intent.

crispy-forms and Django’s own rendering API complement each other more than they compete. Django 5.0’s as_field_group and a project field.html cover the "one markup rule for all fields" case in a few lines and no dependency; crispy covers per-form layouts. The two can be mixed, but crispy’s tags use the template pack’s own templates, so a project-level field.html does not affect |crispy and {% crispy %} output.

Accessibility is rendering’s job

The built-in templates already produce the markup accessibility guidelines ask for, so the way to keep it is to avoid replacing them with weaker markup:

  • every input has a <label for> (or a <legend> inside a <fieldset> for several inputs);

  • help text and errors are linked to the input with aria-describedby, and an invalid input gets aria-invalid="true";

  • required fields get the HTML required attribute (switch it off per form with use_required_attribute = False only when you validate with your own client-side code);

  • radio and checkbox groups are `<fieldset>`s with a legend.

When you write a custom widget or a custom field.html, keep those ids and attributes (field.auto_id, field.aria_describedby), and check the result with a screen reader or an automated audit; the general rules are on Accessibility and the HTML side on Forms: Accessibility & Validation.

Pitfalls

  • Override ignored. The default renderer never reads project DIRS. Use TemplatesSetting and add "django.forms" to INSTALLED_APPS.

  • Autoescaping. help_text|safe in the stock template trusts help text. Never build help text from user data; build HTML in widgets with format_html() or templates, not with string concatenation.

  • Query cost. Rendering a ModelChoiceField evaluates its queryset on each render; render a form once per request, and give the field a lean queryset (only(), select_related()) when the table is large.

  • {{ form.media }} placement. It must be in the page head (or before the widget scripts run); put it in the base template’s {% block extra_head %} rather than in each page when many pages have forms.

  • CSS hooks. Style with widget_type, css_classes() and attrs, not with generated ids, which change with form prefixes and formsets.

  • Do not parse form HTML. Output details (whitespace, attribute order) vary between releases; the tests above assert fragments, never the whole string.

Where the books and older tutorials are outdated

Books and tutorials written for Django 1.11 to 3.x predate most of what this page describes, so expect these differences when following them:

  • Forms are shown as {{ form.as_p }} or {{ form.as_table }}. These still work, but since 4.1 as_div() is the recommended style and since 5.0 it is the default; as_p and as_table remain available.

  • Layouts are hand-written field by field with field.label_tag, field.errors and field.help_text; since 5.0 as_field_group() does it in one expression.

  • Overriding form markup means overriding _html_output() or subclassing BoundField and overriding Field.get_bound_field(). Forms render through templates since 4.0 and _html_output() was deprecated then; bound_field_class made the get_bound_field() override unnecessary in 5.2.

  • Widgets have been rendered through templates since Django 1.11 (the notes call it template-based widget rendering), and Widget.render() takes a renderer argument; a widget written to build strings in render() cannot be customised through templates.

  • django-crispy-forms examples set CRISPY_TEMPLATE_PACK = "bootstrap4" with no extra package; since crispy-forms 2.0 the packs live in separate packages and must be installed and added to INSTALLED_APPS.

  • The transitional renderers DjangoDivFormRenderer and Jinja2DivFormRenderer, suggested in 4.1 to opt in to the div output, were deprecated in 5.0 and removed in 6.0; delete any FORM_RENDERER line that names them.

  • forms.URLField assumed http:// for values without a scheme until Django 6.0, which changed the default to https and removed the transitional FORMS_URLFIELD_ASSUME_HTTPS setting.

What changed recently

Version Change

4.0

Template-based form rendering: Form.render(), get_context(), template_name, formset and ErrorList templates; BaseForm._html_output() deprecated.

4.1

as_div() and div.html; FORM_RENDERER renderer attributes form_template_name, formset_template_name; legend_tag(); use_fieldset on BoundField and Widget; Media accepts objects with an __html__ method.

5.0

as_div becomes the default output; field groups and as_field_group() with field.html; the transitional div renderers are deprecated.

5.2

bound_field_class at renderer, form and field level; BoundField.aria_describedby; ErrorList.field_id; Script asset objects.

6.0

Transitional DjangoDivFormRenderer and Jinja2DivFormRenderer removed; forms.URLField defaults to https.

6.1

Stylesheet asset objects for Media (the 5.2 Script counterpart); BLANK_CHOICE_LABEL for the blank choice label.

The 5.2 LTS supports everything on this page except Stylesheet; the 6.1 only API is called out where it appears.

Where to go next

The form classes and the request flow are on Forms; validation, errors and validators on Form Validation & Validators; model-backed forms, formsets and inline formsets on Model Forms & Formsets; the template language that all of the above use on Templates; and the HTMX approach to partial page updates (re-rendering a single field group from the server) on Template Partials & htmx.