Forms

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, with Pillow 12.3.0 for image fields, all checked on 2026-10-03; where the 5.2 LTS (5.2.17) behaves differently the text says so. Every listing was run in a scratch project on Python 3.13 and Django 6.1.1 with SQLite: manage.py check reported no issues, 25 tests (manage.py test) passed, and a curl session against runserver produced the status codes and fragments shown below. Nothing on this page needs PostgreSQL or Redis. The one piece of code that was not executed is the browser-side JavaScript in JavaScript and fetch: its server side was run with the test client. A form is the way a Django application takes structured input from a person: it declares the expected fields, renders the HTML controls, reads the submitted data, validates and converts it, and keeps the errors so the page can be shown again. In the Bookshelf application that grows across this section (the catalog app), forms are how readers suggest a book, search the catalogue and upload the excerpt of a chapter. This page covers the Form class, bound and unbound forms, fields and widgets, the GET/POST/redirect pattern, CSRF protection and {% csrf_token %}, and file fields; where the form is validated in detail, rendered in detail, or tied to a model, it links to the page that owns that topic instead of repeating it.

In a hurry: what goes where

Need Rule of thumb Section

Describe the input

A subclass of forms.Form with declared fields; fields validate and convert, widgets render and extract.

The Form class, Fields: validation and conversion, Widgets: rendering and extracting

Show an empty form versus show errors

Unbound (Form()) renders initial values and no errors; bound (Form(request.POST)) renders submitted data and errors. Any non-None data, even an empty dict, makes a form bound.

Bound and unbound forms

Handle a submission

if request.method == "POST": bind, is_valid(), act on cleaned_data, then redirect. Otherwise build an unbound form.

The GET/POST/redirect pattern

A search or filter form

method="get", bind to request.GET, no CSRF token, no redirect.

Forms that use GET

Let the framework run the loop

FormView (or CreateView and friends): form_valid() and form_invalid().

The same form in a class-based view

Make a POST pass CSRF

{% csrf_token %} inside every internal POST <form>, rendered with a request (render()); X-CSRFToken for JavaScript.

CSRF protection and {% csrf_token %}

Accept a file

enctype="multipart/form-data", method="post", and Form(request.POST, request.FILES).

File fields

Run the same form on ASGI

Validation is synchronous; a form that touches the ORM (ModelChoiceField) must be validated through sync_to_async().

Sync and async

Test it

The form alone with SimpleTestCase, the view with the test client, CSRF with Client(enforce_csrf_checks=True).

Testing forms and views

Where a form sits in the request cycle

A form is not part of the framework’s request pipeline: nothing in the URL resolver or the middleware knows about Form. Forms are ordinary Python objects that a view creates, feeds with the request’s data and hands to a template. What the framework does around them is the part that matters: the CSRF middleware inspects the submission before your view starts, the request body is parsed lazily when first read, and the CSRF cookie is attached to the response when a template asked for the token.

sequenceDiagram participant B as Browser participant M as CsrfViewMiddleware participant V as View participant F as Form participant T as Template B->>M: POST /catalog/suggest/ with csrfmiddlewaretoken M->>M: compare cookie secret and posted token (reads request.POST) M->>V: request (view runs only if the token is valid) V->>F: BookSuggestionForm(request.POST) V->>F: is_valid() runs full_clean() alt valid V-->>B: 302 redirect to the success page else invalid V->>T: render with the bound form T-->>B: 200 with errors and submitted values end

In order, for one submission:

  1. The server (WSGI or ASGI) builds the HttpRequest. The body is not parsed yet.

  2. Middleware runs top-down. CsrfViewMiddleware.process_view() is the first code that reads request.POST for a POST without the X-CSRFToken header: it needs the csrfmiddlewaretoken field, so the whole form-encoded or multipart body (files included) is parsed here, before the view and before any form exists. A failed check ends the request with a 403 and your view never runs.

  3. The URL resolver picks the view.

  4. The view decides bound or unbound from the HTTP method, builds the form, calls is_valid() (which triggers full_clean() once and caches the result) and chooses the response.

  5. If the template calls {% csrf_token %}, get_token() marks the request so that process_response() adds the csrftoken cookie and a Vary: Cookie header. That is also why a page containing a form is not safely cacheable by a shared cache.

  6. The browser follows the redirect with a fresh GET (or re-renders the page, for an invalid form).

The middleware chain is explained in Middleware and the whole pipeline in The Request/Response Cycle.

What the browser sends: HTML forms, GET and POST

An HTML <form> has an action (the URL, by default the current page), a method and fields with a name. The browser sends the names and values of the controls it submits, encoded as one of three content types:

  • application/x-www-form-urlencoded (the default) for text fields; Django exposes it as request.POST for POST requests and as request.GET for the query string.

  • multipart/form-data, required when the form contains a file input (enctype="multipart/form-data"); text parts appear in request.POST, file parts in request.FILES.

  • text/plain, which is for debugging and which Django does not parse.

A control that is not submitted is simply absent: an unchecked checkbox, an unselected radio group and a disabled control send nothing. That one rule explains several details below (BooleanField(required=False), disabled=True, value_omitted_from_data()).

The choice of method is a design decision, not a style one. GET is for requests that can be repeated, bookmarked, cached and prefetched without harm: a search, a filter, a pagination link. POST is for requests that change state on the server: suggest a book, upload a file, log in. Django enforces that distinction in two places: the CSRF protection applies to every method that is not GET, HEAD, OPTIONS or TRACE, and the request.POST dictionary is empty for a GET. Do not change state in a GET handler; the books' habit of a "delete" or "add to basket" link that works through GET is wrong because a prefetching browser, a crawler or an image tag on another site can trigger it.

Only form-encoded and multipart bodies fill request.POST. A JSON body, as sent by fetch() with Content-Type: application/json, leaves request.POST empty and has to be parsed by hand (or by Django REST framework or Django Ninja); a Form bound to it would report every field as missing.

The Form class

A form is a class whose class attributes that are Field instances become its fields, in declaration order. The metaclass collects them into base_fields; every form instance gets its own deep copy in self.fields, which is why changing form.fields["x"] in a view or in __init__ does not leak into the next request. The Bookshelf suggestion form:

A form with several field types (catalog/forms.py)
# catalog/forms.py
from django import forms
from django.db.models import TextChoices


class Genre(TextChoices):
    FICTION = "fiction", "Fiction"
    SCIENCE = "science", "Science"
    HISTORY = "history", "History"


class BookSuggestionForm(forms.Form):
    title = forms.CharField(max_length=200)
    author = forms.CharField(max_length=120)
    genre = forms.ChoiceField(choices=Genre, initial=Genre.FICTION)
    pages = forms.IntegerField(min_value=1, max_value=5000, required=False)
    published = forms.DateField(
        required=False,
        widget=forms.DateInput(attrs={"type": "date"}),
        help_text="Leave empty if unknown.",
    )
    website = forms.URLField(required=False)
    notes = forms.CharField(
        required=False,
        widget=forms.Textarea(attrs={"rows": 4}),
        max_length=1000,
    )
    notify_me = forms.BooleanField(required=False, label="Tell me when it is added")

Things the class gives you, all documented on the API page:

  • Constructor arguments. Form(data=None, files=None, auto_id="id_%s", prefix=None, initial=None, error_class=ErrorList, label_suffix=None, empty_permitted=False, field_order=None, use_required_attribute=None, renderer=None, bound_field_class=None) (the last one since 5.2). Everything after files is conventionally passed by keyword.

  • Class attributes. field_order (a list of names that moves those fields first), use_required_attribute, default_renderer, template_name and the template_name_div/_p/_ul/_table/_label family, error_css_class, required_css_class, label_suffix, and since 5.2 bound_field_class. The rendering attributes belong to Form Rendering & Widgets.

  • Per-instance results. errors, cleaned_data (only after validation), changed_data, fields, initial, data, files, prefix, is_bound.

  • Methods. is_valid(), full_clean(), clean() and clean_<fieldname>() hooks, add_error(), non_field_errors(), has_changed(), is_multipart(), order_fields(), visible_fields(), hidden_fields(), get_initial_for_field(), iteration (for field in form) and indexing (form["title"]), both of which yield BoundField objects.

Validation (clean_<field>(), clean(), add_error(), validators) is the subject of Form Validation & Validators; binding a form to a model is Model Forms & Formsets.

A form does not have to match a model, a table or an HTTP request: it is a validation and rendering object. The BookSuggestionForm has no model behind it, which is the right shape for input that is processed (a suggestion queued for a librarian) rather than stored one to one.

Bound and unbound forms

A form is bound when it was constructed with data, unbound otherwise. The distinction decides everything about how the form behaves, and it is a plain test in the source: is_bound = data is not None or files is not None.

Unbound, empty-bound, valid and invalid forms (run with manage.py shell)
from catalog.forms import BookSuggestionForm

unbound = BookSuggestionForm()
print(unbound.is_bound, unbound.is_valid(), unbound.errors)
# False False

empty = BookSuggestionForm({})          # an empty dict is still data
print(empty.is_bound, empty.is_valid())
# True False
print(empty.errors.as_json())
# {"title": [{"message": "This field is required.", "code": "required"}], "author": [...], "genre": [...]}

data = {"title": "Dune", "author": "Frank Herbert", "genre": "fiction", "pages": "412",
        "published": "1965-08-01", "notify_me": "on"}
form = BookSuggestionForm(data)
print(form.is_valid())
# True
print(form.cleaned_data)
# {'title': 'Dune', 'author': 'Frank Herbert', 'genre': 'fiction', 'pages': 412,
#  'published': datetime.date(1965, 8, 1), 'website': '', 'notes': '', 'notify_me': True}
print(form.changed_data)
# ['title', 'author', 'pages', 'published', 'notify_me']

bad = BookSuggestionForm({"title": "Dune", "author": "F", "genre": "poetry", "pages": "0", "website": "x"})
print(bad.is_valid())
# False
for name, errs in bad.errors.items():
    print(name, [e for e in errs])
# genre ['Select a valid choice. poetry is not one of the available choices.']
# pages ['Ensure this value is greater than or equal to 1.']
# website ['Enter a valid URL.']
print(bad.cleaned_data)
# {'title': 'Dune', 'author': 'F', 'published': None, 'notes': '', 'notify_me': False}

What the run shows, and what to remember:

  • An unbound form is never valid and has no errors. is_valid() is is_bound and not errors. Rendering an unbound form shows initial values and no error messages, which is what you want for the first GET.

  • An empty dict is bound. BookSuggestionForm({}) reports three "required" errors. The common mistake is to write if request.POST: to detect a submission: an empty POST (a form whose controls were all unchecked checkboxes) is falsy and would be treated as a GET. Test request.method == "POST" instead.

  • cleaned_data holds Python values, not strings. pages is an int, published a date, notify_me a bool. This conversion is the reason to validate through a form rather than read request.POST yourself.

  • After a failed is_valid(), cleaned_data contains only the fields that passed. Never read a key that may be missing; use it only after is_valid() returned True (or in clean() with .get()).

  • Initial values are for display only. genre has initial=Genre.FICTION and is absent from changed_data when the submission equals it, but initial is not a fallback: submit genre="" and the field is "required", not "fiction". Initial data can also come from the constructor (initial={"genre": "science"}, which wins over the field’s initial); callables are evaluated each time an unbound form is displayed.

  • changed_data and has_changed() compare submitted data with the initial values. The model formset machinery uses them to skip untouched extra forms; empty_permitted=True lets a form whose data did not change validate as empty.

  • Every field is validated every time. The validation cycle runs once, on the first access to errors, cleaned_data or is_valid(), and the result is cached on the instance; mutating form.data afterwards has no effect.

Two constructor arguments change how the same form object is used in one page. prefix="shelf" renames every control to shelf-name, shelf-capacity, and so makes it possible to put two forms of the same class in one <form> tag or page; the prefix must be passed when reading the data back. auto_id controls the id attributes ("id_%s" by default, False to omit them), and the label suffix and ordering options (label_suffix, field_order, order_fields()) work as their names say.

Fields: validation and conversion

A Field converts and validates one submitted value. Its clean(value) runs to_python() (convert), validate() (field rules, such as "required") and run_validators() (the field’s own and any you pass in validators), and raises ValidationError or returns the cleaned value. You can call it directly, which is the quickest way to learn what a field does: forms.EmailField().clean("not an address") raises ValidationError: ['Enter a valid email address.'].

The core arguments

Every field class accepts these arguments:

Argument Effect

required

True by default: an empty value (None or "") fails with the required message. Required fields render the HTML required attribute unless the form sets use_required_attribute = False. 0, False and "0" are not empty.

label, label_suffix

The text shown next to the control (default: the field name with underscores replaced and the first letter capitalised); the suffix (default ":") can be overridden per field or per form.

initial

Value (or callable) shown by an unbound form. Never a validation fallback.

widget

A widget class or instance replacing the field’s default control. See Widgets: rendering and extracting.

help_text

Descriptive text rendered with the field; since 5.0 inputs get an aria-describedby that points at it.

error_messages

A dictionary overriding messages by key (required, invalid, max_length, and field-specific keys such as invalid_choice).

validators

A list of callables run after the field’s own validation; see Form Validation & Validators.

localize

True enables localised input and output formats for the value (and forces a TextInput for numbers).

disabled

Renders the disabled attribute and, crucially, ignores whatever the client submits: the value always comes from the form’s initial data. See Pre-filling, read-only fields, extra arguments and field changes.

template_name

Template used by as_field_group() for this field (5.0).

bound_field_class

A per-field replacement for Form.bound_field_class (5.2).

The built-in fields

The table lists every field class in django.forms, its default widget and the Python type of cleaned_data ("" or None for an empty optional value depending on the class). Argument names are the field-specific ones; anything in the core list above also applies.

Field Default widget Cleans to Field-specific arguments and notes

BooleanField

CheckboxInput

bool

With required=True it must be ticked (an "I accept" box). Use required=False for an optional box.

NullBooleanField

NullBooleanSelect

True, False or None

Three-state; the model NullBooleanField was removed in 4.0, the form field remains.

CharField

TextInput

str

max_length, min_length, strip (True), empty_value (""). max_length becomes the HTML maxlength.

EmailField

EmailInput

str

max_length, min_length; validated by EmailValidator.

URLField

URLInput

str

assume_scheme (5.0) is the scheme added to a value without one; its default changed from "http" to "https" in 6.0.

SlugField

TextInput

str

allow_unicode, empty_value.

RegexField

TextInput

str

Takes regex; prefer a validator when the pattern is reused.

UUIDField

TextInput

uuid.UUID

Accepts the usual textual UUID forms.

GenericIPAddressField

TextInput

str

protocol ("both", "IPv4", "IPv6"), unpack_ipv4.

IntegerField

NumberInput

int

max_value, min_value, step_size.

FloatField

NumberInput

float

Same as IntegerField; rejects NaN and infinity.

DecimalField

NumberInput

decimal.Decimal

max_value, min_value, max_digits, decimal_places, step_size. Use it for money, never FloatField.

DateField

DateInput

datetime.date

input_formats; with <input type="date"> browsers always send ISO YYYY-MM-DD.

TimeField

TimeInput

datetime.time

input_formats.

DateTimeField

DateTimeInput

datetime.datetime

input_formats; with USE_TZ = True the value is made aware in the current time zone.

SplitDateTimeField

SplitDateTimeWidget

datetime.datetime

One date and one time control combined; input_date_formats, input_time_formats.

DurationField

TextInput

datetime.timedelta

Accepts DD HH:MM:SS.uuuuuu and ISO 8601 durations.

ChoiceField

Select

str

choices: an iterable of pairs, an enumeration type (5.0), a mapping (5.0) or a callable returning one of those (5.0), evaluated when the form is initialised. Always cleans to a string.

TypedChoiceField

Select

coerce(value)

Adds coerce and empty_value so an integer choice cleans to an int (or None).

MultipleChoiceField

SelectMultiple

list of str

Same choices rules.

TypedMultipleChoiceField

SelectMultiple

list of coerce() results

coerce, empty_value.

ModelChoiceField

Select

a model instance

queryset, empty_label, to_field_name; the queryset is evaluated when the field renders and validates (Sync and async).

ModelMultipleChoiceField

SelectMultiple

QuerySet

Same arguments; the model-bound fields belong to Model Forms & Formsets.

FilePathField

Select

str

path, match, recursive, allow_files, allow_folders; set_choices() (6.1) rescans the directory.

FileField

ClearableFileInput

UploadedFile

max_length (file name length), allow_empty_file. See File fields.

ImageField

ClearableFileInput

UploadedFile with .image

Needs Pillow; adds the invalid_image message and extension validation.

JSONField

Textarea

any JSON value

encoder, decoder; renders the value as JSON text.

ComboField

TextInput

str

fields: validates one value against several fields in turn.

MultiValueField

none (subclass supplies a MultiWidget)

whatever compress() returns

fields, require_all_fields; a base class for composite inputs.

Three of these deserve a closer look because they are misused constantly.

ChoiceField and its siblings. The choices come from the model-field vocabulary since 5.0, so a TextChoices class can be passed directly (choices=Genre) and no .choices property is needed. A ChoiceField always returns a string; for numbers use TypedChoiceField. Both reject any submitted value that is not among the choices, which is a security property (a tampered <select> cannot smuggle a value in) as well as a validation rule. The ModelChoiceField blank label changed in 6.1: the default blank label of choice fields moved from "---------" to "- Select an option -", defined as django.db.models.fields.BLANK_CHOICE_LABEL, and the transitional setting USE_BLANK_CHOICE_DASH brings the old dash back while it lasts (it is deprecated).

BooleanField. forms.BooleanField() is required by default, so a form with an optional checkbox must say required=False; otherwise every submission without the box ticked fails with "This field is required", which is correct for an "accept the terms" box and wrong for a preference. The cleaned value is a real bool.

DecimalField and DateField. They parse according to the active locale only when localize=True (or the widget is configured for it); by default they use the formats listed in input_formats and the ISO formats. A date typed into a plain text input in a format Django does not know is rejected with Enter a valid date.; the cure is a type="date" input (as in the example) or an explicit input_formats list, not a custom parser.

Widgets: rendering and extracting

A widget is the HTML control and the code that reads its value out of the submitted data. A field holds the logic ("is this a date?"), a widget the presentation ("is it a text box, a date picker or three selects?"). Each field class has a default widget (see the table above) and the widget argument replaces it:

class CommentForm(forms.Form):
    name = forms.CharField()
    url = forms.URLField()
    comment = forms.CharField(widget=forms.Textarea)

The built-in widgets, grouped as the reference groups them:

Group Widgets

Text-like inputs

TextInput, NumberInput, EmailInput, URLInput, PasswordInput (does not echo the value back unless render_value=True), HiddenInput, ColorInput, SearchInput and TelInput (the last three added in 5.2), Textarea

Date and time

DateInput, DateTimeInput, TimeInput (each with a format), SelectDateWidget (three selects, with years, months, empty_label), SplitDateTimeWidget, SplitHiddenDateTimeWidget

Choices

CheckboxInput, Select, NullBooleanSelect, SelectMultiple, RadioSelect, CheckboxSelectMultiple

Files

FileInput, ClearableFileInput

Composite and hidden

MultiWidget (base class for widgets made of several controls), MultipleHiddenInput

Things that matter in practice:

  • attrs adds HTML attributes. forms.TextInput(attrs={"autofocus": True, "autocomplete": "off"}) renders the attributes verbatim; True renders a bare attribute (autofocus). This is how you add a CSS class, a placeholder, inputmode or type="date" without touching templates. To set attributes in bulk (every field gets a class), loop over self.fields in the form’s __init__ and update widget.attrs, or use bound_field_class or a template override (see the rendering page).

  • Field type and widget type are independent. DateField(widget=DateInput(attrs={"type": "date"})) still validates a date; replacing the widget does not change what is accepted. Swapping TextInput for HiddenInput does not make the value trustworthy: a hidden field is client data like any other.

  • The widget extracts the value. value_from_datadict(data, files, name) is what reads the raw value (for a SelectDateWidget it joins name_year, name_month, name_day). value_omitted_from_data() tells the form whether the control was submitted at all; CheckboxInput answers False always, because an unticked box is indistinguishable from an omitted one, which is why BooleanField can never be "left alone" in a partial update.

  • Required rendering. A widget decides through use_required_attribute(initial) whether required is emitted; the built-in ClearableFileInput drops it when a file is already stored.

  • Accessibility attributes are automatic. Since 5.0 a field with help text gets aria-describedby, an invalid field gets aria-invalid="true" (5.0), and (5.2) the error list is connected through aria-describedby too; use_fieldset controls whether multi-control widgets are wrapped in a <fieldset> with a <legend>.

  • Writing a widget (a template_name, a get_context() override, a Media class for CSS and JavaScript) is covered in Form Rendering & Widgets.

The GET/POST/redirect pattern

The standard way to handle a form in a view is the same few lines every time, and each line has a reason:

The suggestion view (catalog/views.py)
# catalog/views.py
import logging

from django.contrib import messages
from django.shortcuts import redirect, render

from .forms import BookSuggestionForm

logger = logging.getLogger(__name__)


def suggest_book(request):
    if request.method == "POST":
        form = BookSuggestionForm(request.POST)
        if form.is_valid():
            logger.info("suggestion received: %s", form.cleaned_data["title"])
            messages.success(request, "Thanks, we will look into it.")
            return redirect("catalog:suggest-done")
    else:
        form = BookSuggestionForm()
    return render(request, "catalog/suggest.html", {"form": form})


def suggest_done(request):
    return render(request, "catalog/done.html")
{# catalog/templates/catalog/suggest.html #}
{% include "catalog/messages.html" %}
<h1>Suggest a book</h1>
<form method="post" novalidate>
  {% csrf_token %}
  {{ form }}
  <button type="submit">Send suggestion</button>
</form>
# catalog/urls.py
from django.urls import path

from . import views

app_name = "catalog"
urlpatterns = [
    path("suggest/", views.suggest_book, name="suggest"),
    path("suggest/done/", views.suggest_done, name="suggest-done"),
]

The curl session against runserver (the cookie jar carries the CSRF cookie between requests; the token is read from the form’s hidden input) produced:

$ curl -s -o /dev/null -w "%{http_code}\n" -X POST -d "title=Dune&author=Herbert&genre=fiction" http://127.0.0.1:8133/catalog/suggest/
403                                              # no token: rejected before the view
$ curl -s -b jar.txt -X POST --data-urlencode "csrfmiddlewaretoken=$T" -d "title=&author=H&genre=poetry" .../catalog/suggest/
<ul class="errorlist" id="id_title_error"><li>This field is required.</li></ul>
<ul class="errorlist" id="id_genre_error"><li>Select a valid choice. poetry is not one of the available choices.</li></ul>
200                                              # invalid: the page is shown again, with errors
$ curl -s -b jar.txt -D - -o /dev/null -X POST --data-urlencode "csrfmiddlewaretoken=$T" -d "title=Dune&author=Herbert&genre=fiction" .../catalog/suggest/
HTTP/1.1 302 Found
Location: /catalog/suggest/done/                 # valid: redirect
$ curl -s -b jar.txt -c jar.txt .../catalog/suggest/done/ | grep -E "Thanks|received"
  <li>Thanks, we will look into it.</li>
<p>Suggestion received.</p>

Why each part is the way it is:

  • Three outcomes, one view. GET shows an empty form; an invalid POST shows the same template with the bound form (the user’s input and the errors); a valid POST does its work and redirects. The else branch builds the unbound form, so there is exactly one render() call for both display cases.

  • Redirect after a successful POST (Post/Redirect/Get). If the success page were rendered by the POST itself, a browser refresh would resubmit the form (the "resend form data?" dialog) and create the suggestion twice. A 302 to a GET URL makes refresh harmless and the success page bookmarkable. Use redirect() or HttpResponseRedirect with a named URL through reverse(); never hard-code paths. A 303 See Other is the status that says "fetch this with GET" most precisely; Django’s default 302 is treated as a 303 by every browser for a POST.

  • An invalid form returns status 200. Django’s convention is to re-render with 200, not 400 or 422; browsers show it either way, but many tools treat a 4xx page as a failure. JavaScript clients that need a machine-readable signal use a JSON response, as suggest_api below does with 422.

  • The message survives the redirect. messages.success() stores the text in the session or a cookie, and the next page’s template shows and clears it; see Sessions, Cookies, Messages & Signing.

  • novalidate is a choice. It turns off the browser’s own checks so you see Django’s messages while developing; leave it off in production if you want the browser’s early feedback. Browser checks are a convenience: the server always validates, because a client can send anything.

  • {{ form }} renders the whole form. Since 5.0 it uses the <div>-based template; Rendering a form in a template shows the other ways.

  • Side effects go after is_valid(). Do the work (save, enqueue, send) only with cleaned_data, never with request.POST. Work that must not run if a transaction rolls back belongs in transaction.on_commit(), see Transactions, Raw SQL & Multiple Databases.

The shortcuts and decorators around this pattern (require_POST, require_http_methods, redirect()) are covered in Function-Based Views & Shortcuts, in particular its section on redirects.

Forms that use GET

A form with method="get" puts its fields in the query string, which is exactly right for search and filtering: the result page has a URL a person can bookmark, share and reload. There is no state change, so no CSRF token and no redirect.

A search form bound to request.GET (catalog/forms.py and views.py)
# catalog/forms.py
class SearchForm(forms.Form):
    q = forms.CharField(required=False, label="Search")
    genre = forms.ChoiceField(
        required=False, choices=[("", "Any genre"), *Genre.choices]
    )


# catalog/views.py
BOOKS = [
    {"title": "Dune", "genre": "fiction"},
    {"title": "Cosmos", "genre": "science"},
    {"title": "SPQR", "genre": "history"},
]


def book_search(request):
    form = SearchForm(request.GET or None)
    results = BOOKS
    if form.is_valid():
        q = form.cleaned_data["q"].lower()
        genre = form.cleaned_data["genre"]
        results = [
            b
            for b in BOOKS
            if q in b["title"].lower() and (not genre or b["genre"] == genre)
        ]
    return render(request, "catalog/search.html", {"form": form, "results": results})
{# catalog/templates/catalog/search.html #}
<h1>Search</h1>
<form method="get" action="{% url 'catalog:search' %}">
  {{ form }}
  <button type="submit">Search</button>
</form>
<ul>
  {% for book in results %}<li>{{ book.title }} ({{ book.genre }})</li>{% empty %}<li>No books found.</li>{% endfor %}
</ul>
$ curl -s "http://127.0.0.1:8133/catalog/search/?q=cos&genre=science" | grep "<li>"
  <li>Cosmos (science)</li>

request.GET or None is the idiom for "unbound until the user submits something": an empty query string is an empty QueryDict, which is falsy, so the form stays unbound and shows every book without errors. The pitfall is a form with required fields on a page that other links reach with unrelated query parameters (?page=2): that non-empty query string binds the form and reports "required" errors. In that case test for a field explicitly ("q" in request.GET) or give every search field required=False, as here. A GET form is also the place where pagination links must carry the filter along; the {% querystring %} tag (5.1) does it, see Pagination.

The same form in a class-based view

The three-branch function above is what FormView implements. Its hooks are get_form_class(), get_form_kwargs() (the place to add constructor arguments such as request.user), get_initial(), form_valid(), form_invalid(), get_success_url() and get_context_data().

FormView with the same form (catalog/views.py)
from django.urls import reverse_lazy
from django.views.generic import FormView


class SuggestBookView(FormView):
    template_name = "catalog/suggest.html"
    form_class = BookSuggestionForm
    success_url = reverse_lazy("catalog:suggest-done")

    def form_valid(self, form):
        logger.info("suggestion received: %s", form.cleaned_data["title"])
        messages.success(self.request, "Thanks, we will look into it.")
        return super().form_valid(form)   # redirects to success_url

FormView binds the form with request.POST and request.FILES on POST, redirects to success_url after form_valid() and re-renders with the bound form after form_invalid(); success_url must be lazy (reverse_lazy) because the URLconf is not loaded when the class body runs. The tests below ran both views against the same assertions. Use a function view when the flow is unusual and a FormView when it is the standard loop; the class-based machinery is in Class-Based Views and Generic Views.

CSRF protection and {% csrf_token %}

Cross-site request forgery makes a logged-in person’s browser send a request they did not intend: a hidden form on another site that POSTs to /catalog/suggest/ rides on the session cookie the browser attaches automatically. Django’s defence is the synchroniser token pattern implemented by CsrfViewMiddleware, which is in the default MIDDLEWARE list:

  1. A random secret is stored in the csrftoken cookie (changed at login).

  2. The {% csrf_token %} tag renders <input type="hidden" name="csrfmiddlewaretoken" value="…​">. The value is the secret masked with a fresh random mask on every render (so the HTML differs each time, as a defence against BREACH-style compression attacks).

  3. For every request whose method is not GET, HEAD, OPTIONS or TRACE the middleware requires the cookie and a matching token, from the csrfmiddlewaretoken form field or from the X-CSRFToken header (CSRF_HEADER_NAME).

  4. It also checks the Origin header against the current host and CSRF_TRUSTED_ORIGINS, and, for HTTPS requests with no Origin, the Referer strictly. On failure the response is a 403, produced by the CSRF_FAILURE_VIEW (django.views.csrf.csrf_failure by default) and logged on the django.security.csrf logger.

An attacker’s page can make the browser send the cookie but cannot read it (same-origin policy), so it cannot produce the matching token. The protection assumes HTTPS with HSTS, validated Host headers and no XSS: an XSS hole already gives the attacker everything CSRF would.

Using the tag

<form method="post">
  {% csrf_token %}
  ...
</form>

Rules, from the how-to and the reference:

  • Put {% csrf_token %} inside every <form method="post"> that targets an internal URL.

  • Never put it in a form whose action is an external site: the token would be leaked to that site.

  • The template must be rendered with a RequestContext, which render(), the generic views and the contrib apps already do. If you build the response with Template.render(Context(…​)) and no request, the tag renders nothing and the POST fails with 403.

  • GET forms need no token, and adding one only leaks it into URLs, logs and referrers.

  • In the Jinja2 backend the equivalent is {{ csrf_input }}, which Django adds to the context.

  • The token makes a page Vary: Cookie and sets a cookie, so it interacts with caching; the how-to explains the cache_page/ensure_csrf_cookie combinations (see Security for the whole security picture).

The Client(enforce_csrf_checks=True) tests below prove the rejection and the acceptance paths; the default test client does not enforce CSRF, so an ordinary test with client.post() passes even if the template forgot the tag, and only the curl session (or a test that reads the token from the rendered page and posts it back) proves the template is right.

CSRF behaviour under test (catalog/tests.py)
from django.test import Client, TestCase
from django.urls import reverse

VALID = {"title": "Dune", "author": "Frank Herbert", "genre": "fiction", "pages": "412"}


class CsrfTests(TestCase):
    def test_post_without_token_is_rejected(self):
        client = Client(enforce_csrf_checks=True)
        response = client.post(reverse("catalog:suggest"), VALID)
        self.assertEqual(response.status_code, 403)

    def test_post_with_token_is_accepted(self):
        client = Client(enforce_csrf_checks=True)
        page = client.get(reverse("catalog:suggest"))
        token = page.cookies["csrftoken"].value
        response = client.post(reverse("catalog:suggest"), {**VALID, "csrfmiddlewaretoken": token})
        self.assertEqual(response.status_code, 302)

(The token read from the cookie is accepted because the middleware compares secrets, not masked values.)

JavaScript and fetch

A same-origin script that posts without a form sends the token in a header. The server side below was run (the test posts with headers={"X-CSRFToken": token} and checks a 201 and a 422); the browser snippet is the pattern from the official how-to and was not executed here.

# catalog/views.py
def suggest_api(request):
    # same-origin JavaScript posts form-encoded data with the X-CSRFToken header
    form = BookSuggestionForm(request.POST)
    if form.is_valid():
        return JsonResponse({"ok": True}, status=201)
    return JsonResponse({"ok": False, "errors": form.errors.get_json_data()}, status=422)
// token from the hidden input of any {% csrf_token %} form on the page
const csrftoken = document.querySelector('[name=csrfmiddlewaretoken]').value;

const request = new Request("/catalog/api/suggest/", {
  method: "POST",
  headers: {"X-CSRFToken": csrftoken},
  body: new FormData(document.querySelector("#suggest-form")),
  mode: "same-origin",            // never send the token to another origin
});
fetch(request).then((response) => response.json());
Header variant under test (catalog/tests.py)
def test_header_variant_for_javascript(self):
    client = Client(enforce_csrf_checks=True)
    client.get(reverse("catalog:suggest"))
    token = client.cookies["csrftoken"].value
    response = client.post(reverse("catalog:suggest-api"), VALID, headers={"X-CSRFToken": token})
    self.assertEqual(response.status_code, 201)
    bad = client.post(reverse("catalog:suggest-api"), {"title": ""}, headers={"X-CSRFToken": token})
    self.assertEqual(bad.status_code, 422)
    self.assertEqual(bad.json()["errors"]["title"][0]["code"], "required")

If the page has no form (so no csrfmiddlewaretoken input), take the token from the cookie, and if CSRF_USE_SESSIONS or CSRF_COOKIE_HTTPONLY is true the cookie is not readable by script; render the token into the page with {{ csrf_token }} instead. The how-to lists both variants. Use @ensure_csrf_cookie on a view that serves such a page but has no form.

CSRF settings and decorators

Item Notes

CSRF_COOKIE_NAME, CSRF_COOKIE_AGE, CSRF_COOKIE_DOMAIN, CSRF_COOKIE_PATH

Cookie name (csrftoken), lifetime (one year), and scope. A domain such as .example.com allows cross-subdomain posts.

CSRF_COOKIE_SECURE, CSRF_COOKIE_HTTPONLY, CSRF_COOKIE_SAMESITE

Secure flag (set it with HTTPS), HttpOnly (breaks reading the cookie from JavaScript), SameSite ("Lax" by default).

CSRF_USE_SESSIONS

Store the secret in the session instead of a cookie.

CSRF_HEADER_NAME

The request.META key of the header, "HTTP_X_CSRFTOKEN" by default (the header is X-CSRFToken).

CSRF_TRUSTED_ORIGINS

Origins allowed to post cross-origin or behind a reverse proxy; each entry must include the scheme (https://app.example.com), required since 4.0.

CSRF_FAILURE_VIEW

A function view(request, reason="") that renders the 403 page; use it for a friendly message.

@csrf_protect

Enforces the check on one view; use it only if the middleware is removed, and then on both the view that renders the token and the one that receives the POST.

@csrf_exempt

Turns the check off for a view. Legitimate for a webhook that authenticates in some other way (a signature); never a fix for "my form gives 403".

@requires_csrf_token

Makes {% csrf_token %} work without enforcing the check.

@ensure_csrf_cookie

Forces the cookie to be sent by a view that has no {% csrf_token %}.

The mass of "403 CSRF verification failed" reports reduce to a short list: the tag is missing (or is in a form that renders without a request), the cookie is blocked or was never set (the page was served from a cache, or the form comes from a different host), the site runs behind a TLS-terminating proxy and CSRF_TRUSTED_ORIGINS or SECURE_PROXY_SSL_HEADER is not configured, or a Referrer-Policy: no-referrer header makes the strict HTTPS referer check fail. The reason string is in the 403 page and the django.security.csrf log record.

File fields

A file input needs three things on the HTML side and one on the Python side. The form must use method="post", the form tag must carry enctype="multipart/form-data", and the view must pass request.FILES as the second argument. If any of the three is missing the file is silently absent and the form reports "This field is required".

A form with file fields (catalog/forms.py)
from django.core.exceptions import ValidationError
from django.core.validators import FileExtensionValidator


def validate_max_size(file, limit=1024 * 1024):
    if file.size > limit:
        raise ValidationError(
            "File too large (%(size)d bytes, limit %(limit)d).",
            code="too_large",
            params={"size": file.size, "limit": limit},
        )


class ExcerptUploadForm(forms.Form):
    title = forms.CharField(max_length=100)
    excerpt = forms.FileField(
        validators=[FileExtensionValidator(["pdf", "txt"]), validate_max_size],
        max_length=100,
    )
    cover = forms.ImageField(required=False)
The upload view and its template
# catalog/views.py
from django.core.files.storage import default_storage


def upload_excerpt(request):
    if request.method == "POST":
        form = ExcerptUploadForm(request.POST, request.FILES)
        if form.is_valid():
            excerpt = form.cleaned_data["excerpt"]
            saved_name = default_storage.save(f"excerpts/{excerpt.name}", excerpt)
            messages.success(request, f"Stored as {saved_name}")
            return redirect("catalog:upload")
    else:
        form = ExcerptUploadForm()
    return render(request, "catalog/upload.html", {"form": form})
{# catalog/templates/catalog/upload.html #}
<form method="post" enctype="multipart/form-data">
  {% csrf_token %}
  {{ form }}
  <button type="submit">Upload</button>
</form>

The real output of a curl session (note the second request sends the field as a plain form value, as a form without enctype would):

$ curl -s -b jar.txt -c jar.txt -L -F "csrfmiddlewaretoken=$T" -F title=Ch1 -F "excerpt=@ch1.txt" http://127.0.0.1:8133/catalog/upload/ | grep Stored
  <li>Stored as excerpts/ch1.txt</li>
$ curl -s -b jar.txt -X POST --data-urlencode "csrfmiddlewaretoken=$T" -d "title=Ch1&excerpt=ch1.txt" .../catalog/upload/ | grep -A1 errorlist
<ul class="errorlist" id="id_excerpt_error"><li>This field is required.</li></ul>
<input type="file" name="excerpt" required aria-invalid="true" aria-describedby="id_excerpt_error" id="id_excerpt">
File fields under test (catalog/tests.py)
import io

from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import SimpleTestCase
from PIL import Image

from .forms import ExcerptUploadForm


class UploadTests(SimpleTestCase):
    def test_form_needs_files_argument(self):
        data = {"title": "Chapter 1"}
        files = {"excerpt": SimpleUploadedFile("ch1.txt", b"hello")}
        self.assertFalse(ExcerptUploadForm(data).is_valid())
        form = ExcerptUploadForm(data, files)
        self.assertTrue(form.is_valid(), form.errors)
        self.assertEqual(form.cleaned_data["excerpt"].name, "ch1.txt")

    def test_extension_and_size_validators(self):
        data = {"title": "x"}
        bad_ext = ExcerptUploadForm(data, {"excerpt": SimpleUploadedFile("a.exe", b"x")})
        self.assertEqual(bad_ext.errors["excerpt"].as_data()[0].code, "invalid_extension")
        big = ExcerptUploadForm(data, {"excerpt": SimpleUploadedFile("a.txt", b"x" * (1024 * 1024 + 1))})
        self.assertEqual(big.errors["excerpt"].as_data()[0].code, "too_large")

    def test_empty_file_is_rejected_unless_allowed(self):
        form = ExcerptUploadForm({"title": "x"}, {"excerpt": SimpleUploadedFile("a.txt", b"")})
        self.assertEqual(form.errors["excerpt"].as_data()[0].code, "empty")

    def test_image_field_uses_pillow(self):
        buf = io.BytesIO()
        Image.new("RGB", (3, 2)).save(buf, "PNG")
        files = {
            "excerpt": SimpleUploadedFile("a.txt", b"x"),
            "cover": SimpleUploadedFile("c.png", buf.getvalue(), "image/png"),
        }
        form = ExcerptUploadForm({"title": "x"}, files)
        self.assertTrue(form.is_valid(), form.errors)
        self.assertEqual(form.cleaned_data["cover"].image.width, 3)
        fake = {**files, "cover": SimpleUploadedFile("c.png", b"not an image", "image/png")}
        self.assertEqual(ExcerptUploadForm({"title": "x"}, fake).errors["cover"].as_data()[0].code, "invalid_image")
import shutil
import tempfile

from django.test import TestCase, override_settings
from django.core.files.uploadedfile import SimpleUploadedFile


class UploadViewTests(TestCase):
    def setUp(self):
        self.media = tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, self.media)

    def test_upload_stores_file_under_a_safe_name(self):
        with override_settings(MEDIA_ROOT=self.media):
            response = self.client.post(
                reverse("catalog:upload"),
                {"title": "x", "excerpt": SimpleUploadedFile("../../evil.txt", b"hello")},
                follow=True,
            )
        self.assertContains(response, "Stored as excerpts/evil")

What to know about file fields:

  • FileField cleans to an UploadedFile (InMemoryUploadedFile below 2.5 MB, TemporaryUploadedFile above, controlled by FILE_UPLOAD_MAX_MEMORY_SIZE) with name, size, content_type and read()/chunks(). The name is the client’s, reduced to a base name; it is still client data, which is why the view stores it through default_storage and not through open(os.path.join(…​)). The test of the view posts ../../evil.txt and checks that the stored name is excerpts/evil…​ inside MEDIA_ROOT.

  • The validators in the example run in order. Extension and size are cheap and happen before any heavy work. They are not a security check: the extension and content_type are client claims, so serving uploaded files safely is a separate topic (File Uploads & Storage, including multiple files, upload handlers, storage backends and private files).

  • ImageField needs Pillow and opens the image to verify it (invalid_image for a corrupt or fake file), attaching the Pillow object as .image on the cleaned file. Pillow closes the file descriptor after verifying, so non-pixel attributes (width, height, format) remain readable but pixel methods require re-opening the file. Passing validation proves that Pillow can parse the file, not that it is harmless.

  • max_length limits the file name, not the size; allow_empty_file=True accepts a zero-byte file (the default rejects it with the empty code). There is no built-in size limit on the field: use a validator (as above), DATA_UPLOAD_MAX_MEMORY_SIZE (2.5 MB, counts everything in the body except file parts), DATA_UPLOAD_MAX_NUMBER_FILES (100) and DATA_UPLOAD_MAX_NUMBER_FIELDS (1000), and a limit at the reverse proxy.

  • Widgets. ClearableFileInput is the default; when the form has an initial file (an edit form) it shows the current file and a "Clear" checkbox for optional fields. FileInput is the plain control. Neither accepts multiple unless the widget class sets allow_multiple_selected = True (the fix for CVE-2023-31047 in 4.2.1, 4.1.9 and 3.2.19), and a field that validates every file needs a custom FileField subclass as well.

  • Request-cycle position. Because CsrfViewMiddleware reads request.POST for the token, the multipart body (and so the upload) is parsed in the middleware, before the view. The consequence: upload handlers cannot be swapped inside the view on the same request unless the view is exempt from the middleware’s parse and protected by csrf_protect inside, which is described in the file-uploads topic guide.

Pre-filling, read-only fields, extra arguments and field changes

# catalog/forms_extra.py
from django import forms


class ShelfForm(forms.Form):
    name = forms.CharField(max_length=60)
    capacity = forms.TypedChoiceField(
        choices=[("", "Not set"), (10, "Small"), (50, "Large")],
        coerce=int, empty_value=None, required=False,
    )
    owner = forms.CharField(disabled=True, initial="library")

    def __init__(self, *args, owner=None, **kwargs):
        super().__init__(*args, **kwargs)
        if owner is not None:
            self.fields["owner"].initial = owner
            self.initial["owner"] = owner
        self.fields["name"].widget.attrs.update({"autofocus": True, "autocomplete": "off"})
f = ShelfForm(owner="ana")
print(f.as_p())     # the owner control renders as: <input type="text" name="owner" value="ana" required disabled id="id_owner">

g = ShelfForm({"name": "Poetry", "capacity": "50", "owner": "mallory"}, initial={"owner": "ana"})
print(g.is_valid(), g.cleaned_data, g.changed_data)
# True {'name': 'Poetry', 'capacity': 50, 'owner': 'ana'} ['name', 'capacity']

h = ShelfForm(prefix="shelf", owner="ana")
print(h["name"])
# <input type="text" name="shelf-name" maxlength="60" autofocus autocomplete="off" required id="id_shelf-name">
  • disabled=True ignores the client. The attacker’s owner=mallory is discarded and the initial value ana is what ends up in cleaned_data. This is the correct way to show a read-only value; a readonly HTML attribute alone is only a hint to the browser. Combine it with a clean_<field> hook if the value must be re-checked.

  • Extra constructor arguments go through __init__. Take them as keyword-only parameters before calling super().init() and never as a positional argument that shifts data. The common case is request.user or a queryset limited to the current user; a FormView supplies it through get_form_kwargs().

  • Per-instance changes. self.fields is a per-instance deep copy, so modifying a field’s choices, queryset, initial or widget.attrs in __init__ is safe. Changing the class-level field (ShelfForm.base_fields) at request time is not: it is shared by every request.

  • TypedChoiceField converts. The blank option cleans to None through empty_value, and "50" cleans to 50.

Rendering a form in a template

The form object renders itself when printed. {{ form }} (and {{ form.as_div }}) uses the <div> template since 5.0; as_p, as_ul and as_table are the older layouts, and each field has as_field_group (5.0). All of them use the form renderer and the templates shown in Form Rendering & Widgets; the quick reference is:

{{ form }}                      {# every field, as <div> blocks (the default since 5.0) #}
{{ form.as_p }}                 {# <p> per field #}
{{ form.title.as_field_group }} {# label, help text, errors and widget for one field (5.0) #}

{{ form.non_field_errors }}     {# errors raised in clean() and add_error(None, ...) #}
{{ form.title.errors }}         {# the ErrorList of one field #}
{{ form.title.label_tag }}      {{ form.title }}   {{ form.title.help_text }}
{% for field in form.hidden_fields %}{{ field }}{% endfor %}
{% for field in form.visible_fields %}...{% endfor %}

A BoundField (what form["title"] and {% for field in form %} produce) exposes name, html_name, id_for_label, label, help_text, value(), errors, is_hidden, field, form, data, initial, css_classes(), label_tag(), legend_tag() (4.1), aria_describedby (5.2) and as_field_group() (5.0). A small run:

bf = SearchForm({"q": "dune"})["q"]
print(bf.name, bf.html_name, bf.id_for_label, bf.value(), bf.label, bf.is_hidden, bf.errors)
# q q id_q dune Search False
print(SearchForm()["q"].as_field_group())
# <label for="id_q">Search:</label>
# <input type="text" name="q" id="id_q">
print(SearchForm(use_required_attribute=False)["q"])
# <input type="text" name="q" id="id_q">

Two security notes belong here. Field and error text is escaped on output; help_text is not escaped (it is meant to carry markup), so never build it from user input without format_html(). And a manual loop that forgets {% csrf_token %}, the hidden fields or form.non_field_errors produces a form that looks right and silently fails: prefer {{ form }} or as_field_group, and test with a POST through the rendered HTML.

Testing forms and views

Test the form on its own (cheap, no database, no URLs), then the view (status codes, redirect, context, messages), then CSRF (with enforce_csrf_checks). SimpleTestCase.assertFormError() takes the form instance, the field name and the expected message; the old assertFormError(response, "form", "field", msg) signature was deprecated in 4.1 and removed in 5.0.

Form and view tests (catalog/tests.py)
from django.test import SimpleTestCase, TestCase
from django.urls import reverse

from .forms import BookSuggestionForm

VALID = {"title": "Dune", "author": "Frank Herbert", "genre": "fiction", "pages": "412"}


class BookSuggestionFormTests(SimpleTestCase):
    def test_unbound_form_is_not_valid_and_has_no_errors(self):
        form = BookSuggestionForm()
        self.assertFalse(form.is_bound)
        self.assertFalse(form.is_valid())
        self.assertEqual(form.errors, {})

    def test_empty_dict_is_bound(self):
        form = BookSuggestionForm({})
        self.assertTrue(form.is_bound)
        self.assertEqual(set(form.errors), {"title", "author", "genre"})

    def test_cleaned_data_is_typed(self):
        form = BookSuggestionForm(VALID)
        self.assertTrue(form.is_valid(), form.errors)
        self.assertEqual(form.cleaned_data["pages"], 412)
        self.assertIs(form.cleaned_data["notify_me"], False)

    def test_field_errors(self):
        form = BookSuggestionForm({**VALID, "pages": "0", "genre": "poetry"})
        self.assertFormError(form, "pages", "Ensure this value is greater than or equal to 1.")
        self.assertFormError(
            form, "genre", "Select a valid choice. poetry is not one of the available choices."
        )

    def test_initial_is_not_a_fallback(self):
        form = BookSuggestionForm({**VALID, "genre": ""})
        self.assertFormError(form, "genre", "This field is required.")


class SuggestViewTests(TestCase):
    def test_get_renders_unbound_form_with_csrf_token(self):
        response = self.client.get(reverse("catalog:suggest"))
        self.assertEqual(response.status_code, 200)
        self.assertFalse(response.context["form"].is_bound)
        self.assertContains(response, "csrfmiddlewaretoken")

    def test_invalid_post_rerenders_with_200_and_errors(self):
        response = self.client.post(reverse("catalog:suggest"), {"title": ""})
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "This field is required.")
        self.assertTrue(response.context["form"].is_bound)

    def test_valid_post_redirects(self):
        with self.assertLogs("catalog.views", "INFO") as logs:
            response = self.client.post(reverse("catalog:suggest"), VALID)
        self.assertRedirects(response, reverse("catalog:suggest-done"))
        self.assertIn("Dune", logs.output[0])

    def test_message_survives_the_redirect(self):
        response = self.client.post(reverse("catalog:suggest"), VALID, follow=True)
        self.assertContains(response, "Thanks, we will look into it.")

    def test_class_based_view_behaves_the_same(self):
        url = reverse("catalog:suggest-cbv")
        self.assertEqual(self.client.get(url).status_code, 200)
        self.assertRedirects(self.client.post(url, VALID), reverse("catalog:suggest-done"))

The suite also contains the search view, the ShelfForm and the CSRF and upload tests shown above; manage.py test ran 25 tests, all passing. The wider testing toolbox is in Testing.

Sync and async

Forms have no asynchronous API: is_valid(), full_clean(), clean() and every clean_<field>() hook are plain synchronous methods, and there is no ais_valid(). That is harmless in an async def view as long as validation is pure Python, and a bug when it touches the database:

  • A form made of ordinary fields (CharField, ChoiceField, FileField with validators that read bytes) can be validated directly inside an async view; the work is CPU-bound and short. request.POST can also be read in an async view, because the ASGI handler has already buffered the body.

  • ModelChoiceField, ModelMultipleChoiceField, unique checks in a ModelForm, and any clean_<field> hook that runs a query evaluate a queryset in the synchronous API. Called from the event loop, Django raises SynchronousOnlyOperation. Run the whole validation in a thread with sync_to_async() (or asyncio.to_thread()).

A form without the database in an async view, and one with it (catalog/views.py)
from asgiref.sync import sync_to_async
from django.http import HttpResponse, JsonResponse


async def async_search(request):
    form = SearchForm(request.GET or None)
    ok = form.is_valid() if form.is_bound else False
    return JsonResponse({"bound": form.is_bound, "valid": ok})


async def pick_reviewer_wrong(request):
    form = ReviewerForm(request.POST)        # ReviewerForm has a ModelChoiceField
    return HttpResponse(str(form.is_valid()))  # raises SynchronousOnlyOperation


async def pick_reviewer(request):
    form = ReviewerForm(request.POST)
    valid = await sync_to_async(form.is_valid)()
    return HttpResponse(str(valid))
# catalog/forms.py
from django.contrib.auth import get_user_model


class ReviewerForm(forms.Form):
    reviewer = forms.ModelChoiceField(queryset=get_user_model().objects.all())
Async tests (catalog/tests.py)
from django.contrib.auth import get_user_model
from django.core.exceptions import SynchronousOnlyOperation
from django.test import AsyncClient, TestCase


class AsyncTests(TestCase):
    async def test_form_without_database_access_works_in_async_view(self):
        response = await AsyncClient().get("/catalog/async/search/", {"q": "x"})
        self.assertEqual(response.json(), {"bound": True, "valid": True})

    async def test_model_choice_field_needs_sync_to_async(self):
        user = await get_user_model().objects.acreate_user("ana")
        client = AsyncClient()
        with self.assertRaises(SynchronousOnlyOperation):
            await client.post("/catalog/async/reviewer-wrong/", {"reviewer": user.pk})
        response = await client.post("/catalog/async/reviewer/", {"reviewer": user.pk})
        self.assertEqual(response.content, b"True")

Further notes: the CSRF middleware is sync and async capable, so it does not force a thread hop when the view is async (the cost of adaptation is described on the middleware page); template rendering with {{ form }} is synchronous too, and a ModelChoiceField evaluates its queryset when it renders, so a form with a model field needs the database in the render step as well. The pragmatic rule is that views that process forms are usually better written as ordinary synchronous views: Django runs them in a thread pool under ASGI, and nothing is gained by writing the form step as async while the validation has to be wrapped anyway. See Async Support.

Pitfalls

  • Detecting a submission with if request.POST. An empty POST is falsy; use request.method == "POST".

  • Forgetting request.FILES or enctype. The file is silently absent; the field says "This field is required".

  • @csrf_exempt to cure a 403. It removes the protection. Add {% csrf_token %}, render with a request, or fix CSRF_TRUSTED_ORIGINS.

  • {% csrf_token %} in a form with an external action. It leaks the token to that site.

  • Rendering the success page from the POST. Refresh resubmits; redirect (Post/Redirect/Get).

  • Changing state in a GET view. CSRF does not protect GET; browsers prefetch and crawlers follow links.

  • Reading request.POST[…​] instead of cleaned_data. You skip validation, conversion and normalisation.

  • Reading cleaned_data after a failed is_valid(). Keys of failed fields are missing.

  • BooleanField without required=False. The checkbox becomes mandatory.

  • Believing initial is a default. It is only for display; a missing value fails "required".

  • Treating HiddenInput or readonly as trusted. Use disabled=True and validate on the server; a hidden control is user input.

  • Passing request.POST to a form behind a JSON API. A JSON body leaves request.POST empty.

  • Mutating the class-level fields at request time. Change self.fields in __init__.

  • Dynamic choices computed at import time. choices=[…​ for x in Model.objects.all()] in the class body runs a query at import time and freezes the list; pass a callable, use a ModelChoiceField, or set the choices in __init__.

  • Mixing a validation concern into the template. Show form.errors, non_field_errors and each field’s errors, or users will not see why the page came back.

  • fields = "all" and exclude in model forms. Mass assignment: always list the fields explicitly (see Model Forms & Formsets).

  • Trusting the browser’s required and maxlength. They are hints; every rule must also exist on the server.

  • Validating a ModelChoiceField form in an async view without sync_to_async(). SynchronousOnlyOperation.

Where the books are outdated

The four reference books target Django 1.11 to 2.1 (2017-2018); the concepts they teach for forms are still right (declare fields, bind, validate, redirect), but a good deal of the surrounding code has moved on:

  • Manual field-by-field rendering loops. Hand-written {% for field in form %} blocks printing the label, the errors and the widget, and {{ form.as_table }} wrapped in <table>, were the norm. Forms render through templates since 4.0, as_div arrived in 4.1 and became the default for {{ form }} in 5.0, and as_field_group (5.0) renders one field with its label, help text and errors in one call.

  • Hand-written CSRF exemption for AJAX. Books that add a CsrfExemptMixin or @csrf_exempt to make a JavaScript POST work are wrong; the documented answer is the X-CSRFToken header (JavaScript and fetch).

  • CSRF_TRUSTED_ORIGINS without a scheme. Entries must include https:// since 4.0; the old bare host names make the system check fail.

  • ugettext/ugettext_lazy for labels and error messages. Removed in 4.0; use gettext_lazy.

  • url() in form action reverse examples. django.conf.urls.url() was removed in 4.0; use path()/re_path().

  • assertFormError(response, "form", "field", message). The signature was deprecated in 4.1 and removed in 5.0; pass the form (response.context["form"]).

  • attrs={"multiple": True} on file inputs. Raises ValueError since 4.2.1 (4.1.9, 3.2.19); see File fields.

  • URLField defaulting to http. The default scheme for a value typed without one changed to https in 6.0 (the assume_scheme argument came in 5.0, and the FORMS_URLFIELD_ASSUME_HTTPS transitional setting is gone).

  • ModelChoiceField blank label ---------. It is - Select an option - in 6.1.

  • Choices as a hand-built list of tuples only. ChoiceField also accepts an enumeration, a mapping or a callable (5.0), which is much tidier with TextChoices (3.0).

  • render_to_response() (removed 3.0) and request.REQUEST (long gone) in form views; use render() and request.POST or request.GET.

  • django.shortcuts.render_to_response and ModelForm without Meta.fields. The latter has raised ImproperlyConfigured since 1.8; the books' habit of fields = "all" still works and is a security flaw (mass assignment).

  • jQuery date pickers and Bootstrap 3/4 markup. Today <input type="date"> (via DateInput(attrs={"type": "date"})) and the framework of your choice (see the crispy-forms discussion in the rendering page) replace them.

What changed recently

Version Change

4.0

Forms (and formsets and error lists) render through the template engine; CSRF_TRUSTED_ORIGINS entries need a scheme; django.conf.urls.url() removed; DeleteView uses FormMixin (a form can confirm deletion).

4.1

as_div() and div.html; FORM_RENDERER classes get form_template_name; legend_tag() and use_fieldset; new assertFormError() signature that takes a form; DATA_UPLOAD_MAX_NUMBER_FILES (4.1.7).

4.2

ModelForm accepts Meta.formfield_callback; the multiple-file widget guard (4.2.1).

5.0

{{ form }} renders as <div> by default; field groups (as_field_group()); aria-describedby and aria-invalid on fields; URLField(assume_scheme=…​); ChoiceField.choices accepts enumerations, mappings and callables; old assertFormError() signature removed; the transitional div renderers deprecated.

5.1

The form fieldset gets an aria-describedby that points at its help text; {% querystring %} for GET forms and pagination.

5.2

bound_field_class (form and field level) replaces the custom-get_bound_field() workaround; ColorInput, SearchInput, TelInput; the aria_describedby property on BoundField; error lists get an id through field_id; Script assets in form media.

6.0

URLField assumes https; the transitional DjangoDivFormRenderer/Jinja2DivFormRenderer removed.

6.1

FilePathField.set_choices(); BLANK_CHOICE_LABEL and the new default blank label (USE_BLANK_CHOICE_DASH brings back the old one, deprecated); Stylesheet assets in form media; RedirectView.preserve_request (307/308 redirects that keep method and body).

Source: Django 6.1 release notes: Forms and the release notes of 4.0, 4.1, 4.2, 5.0, 5.1, 5.2 and 6.0 listed in References

The book and the official-documentation material behind this page are listed in the section bibliography. The pages that continue the topic are: