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 |
|
|
Render one field with label, help, errors |
|
|
Change the markup of every form in the project |
|
|
Change the markup of one form, field or render call |
|
|
Add a CSS class to every label or field wrapper |
A |
|
Change what an |
Override the widget template ( |
|
Write a new widget |
Subclass |
|
Ship a widget’s CSS and JavaScript |
An inner |
|
Get Bootstrap markup without writing templates |
|
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.
(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 |
|
|
|
Field group |
|
|
|
Widget |
|
|
|
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.
Source: The form rendering API
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.
Source: Working with form templates
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>
Source: Reusable field group templates
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 |
|---|---|
|
The |
|
The generated |
|
The label text, the help text, the |
|
The label as |
|
The widget only, the whole group, and the hidden-input form. |
|
The CSS classes for the row: |
|
Lower-cased widget class name without |
|
Whether the field needs a |
|
The inputs of a multi-input widget, for rendering radio buttons one by one. |
|
Branch on these in templates instead of re-deriving them. |
Source: Attributes of BoundField
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 |
|---|---|
|
Uses its own standalone Django template engine, unrelated to your |
|
The same, with a Jinja2 backend and templates in a |
|
Looks templates up through whatever |
Source: Built-in-template form renderers
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"
Source: 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 |
|
Form |
|
Field |
|
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
Source: Customizing 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,
)
Source: Customizing widget instances
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 |
|---|---|---|
|
|
All extend |
|
|
Default |
|
|
|
|
|
|
|
|
Several |
|
|
Need |
|
|
Several inputs combined into one value; |
|
|
Input subclasses with a fixed |
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_nameis looked up through the renderer, like every form template. -
__init__takesattrsand 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 undercontext["widget"]; the template receives it aswidget. -
Reusing
input.htmlwithincludeinherits the standardname,valueandattrshandling, including thearia-*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 readsdata.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_microsecondsandis_hiddenare the class-level switches (is_hiddenis derived frominput_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>
Source: Writing your own widget
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.
Source: MultiWidget and MultiValueField
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)
Source: Form Assets (the Media class)
What the Media options do
-
cssis a dictionary from output medium (all,screen,print, or a comma-separated list) to a list of files. -
jsis a list of files. A path starting with/,http://orhttps://is used as-is; anything else is prefixed withSTATIC_URL, falling back toMEDIA_URLwhenSTATIC_URLisNone. WithManifestStaticFilesStoragethe hashed name is printed. -
extenddecides inheritance: by default a subclass inherits all assets of its parent;extend = Falsedrops them, which the scratch project confirmed by rendering onlyb.jsfor a subclass declaringjs = ["b.js"]. -
A form can declare its own inner
Media(for layout CSS), and the form’smediaproperty is the union of its own declaration and the media of every widget.mediacan also be a dynamic property that returns aforms.Media. -
Subscripting filters one kind:
form.media["css"]andform.media["js"];Mediaobjects 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>
Source: Paths as objects
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
Layoutexists, only the fields named in it are rendered. Thereading_timefield ofBookFormwas left out of the layout above and therefore silently missing from the page and from the POST data, so the form could never validate (theis_valid()check reportedreading_timeas the only error once the other fields were supplied). Name every field, or build the layout fromself.fields. -
{% crispy form %}already printsform.media; adding{{ form.media }}to the page head as well loads the widget’s assets twice. -
The custom
RatingWidgetandReadingTimeWidgetrendered inside the Bootstrap wrappers and kept theirMedia, but the range input got theform-controlclass, which is the wrong Bootstrap class for a slider (form-range). Custom widgets need a crispy template-pack override or anattrsclass; 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.
Source: django-crispy-forms documentation and crispy-bootstrap5
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 getsaria-invalid="true"; -
required fields get the HTML
requiredattribute (switch it off per form withuse_required_attribute = Falseonly 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. UseTemplatesSettingand add"django.forms"toINSTALLED_APPS. -
Autoescaping.
help_text|safein the stock template trusts help text. Never build help text from user data; build HTML in widgets withformat_html()or templates, not with string concatenation. -
Query cost. Rendering a
ModelChoiceFieldevaluates 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()andattrs, 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.1as_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.errorsandfield.help_text; since 5.0as_field_group()does it in one expression. -
Overriding form markup means overriding
_html_output()or subclassingBoundFieldand overridingField.get_bound_field(). Forms render through templates since 4.0 and_html_output()was deprecated then;bound_field_classmade theget_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 arendererargument; a widget written to build strings inrender()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 toINSTALLED_APPS. -
The transitional renderers
DjangoDivFormRendererandJinja2DivFormRenderer, suggested in 4.1 to opt in to the div output, were deprecated in 5.0 and removed in 6.0; delete anyFORM_RENDERERline that names them. -
forms.URLFieldassumedhttp://for values without a scheme until Django 6.0, which changed the default tohttpsand removed the transitionalFORMS_URLFIELD_ASSUME_HTTPSsetting.
What changed recently
| Version | Change |
|---|---|
4.0 |
Template-based form rendering: |
4.1 |
|
5.0 |
|
5.2 |
|
6.0 |
Transitional |
6.1 |
|
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.