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 |
The Form class, Fields: validation and conversion, Widgets: rendering and extracting |
Show an empty form versus show errors |
Unbound ( |
|
Handle a submission |
|
|
A search or filter form |
|
|
Let the framework run the loop |
|
|
Make a POST pass CSRF |
|
|
Accept a file |
|
|
Run the same form on ASGI |
Validation is synchronous; a form that touches the ORM ( |
|
Test it |
The form alone with |
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.
In order, for one submission:
-
The server (WSGI or ASGI) builds the
HttpRequest. The body is not parsed yet. -
Middleware runs top-down.
CsrfViewMiddleware.process_view()is the first code that readsrequest.POSTfor a POST without theX-CSRFTokenheader: it needs thecsrfmiddlewaretokenfield, 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. -
The URL resolver picks the view.
-
The view decides bound or unbound from the HTTP method, builds the form, calls
is_valid()(which triggersfull_clean()once and caches the result) and chooses the response. -
If the template calls
{% csrf_token %},get_token()marks the request so thatprocess_response()adds thecsrftokencookie and aVary: Cookieheader. That is also why a page containing a form is not safely cacheable by a shared cache. -
The browser follows the redirect with a fresh GET (or re-renders the page, for an invalid form).
Source: Django: Working with forms and CSRF: how it works
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 asrequest.POSTfor POST requests and asrequest.GETfor the query string. -
multipart/form-data, required when the form contains a file input (enctype="multipart/form-data"); text parts appear inrequest.POST, file parts inrequest.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.
Source: Django: HTML forms and HttpRequest.POST
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:
# 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")
Source: Django: The Forms API and Form fields
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 afterfilesis conventionally passed by keyword. -
Class attributes.
field_order(a list of names that moves those fields first),use_required_attribute,default_renderer,template_nameand thetemplate_name_div/_p/_ul/_table/_labelfamily,error_css_class,required_css_class,label_suffix, and since 5.2bound_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()andclean_<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 yieldBoundFieldobjects.
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.
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()isis_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 writeif 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. Testrequest.method == "POST"instead. -
cleaned_dataholds Python values, not strings.pagesis anint,publishedadate,notify_meabool. This conversion is the reason to validate through a form rather than readrequest.POSTyourself. -
After a failed
is_valid(),cleaned_datacontains only the fields that passed. Never read a key that may be missing; use it only afteris_valid()returnedTrue(or inclean()with.get()). -
Initial values are for display only.
genrehasinitial=Genre.FICTIONand is absent fromchanged_datawhen the submission equals it, butinitialis not a fallback: submitgenre=""and the field is "required", not "fiction". Initial data can also come from the constructor (initial={"genre": "science"}, which wins over the field’sinitial); callables are evaluated each time an unbound form is displayed. -
changed_dataandhas_changed()compare submitted data with the initial values. The model formset machinery uses them to skip untouched extra forms;empty_permitted=Truelets 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_dataoris_valid(), and the result is cached on the instance; mutatingform.dataafterwards 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 |
|---|---|
|
|
|
The text shown next to the control (default: the field name with underscores replaced and the first letter capitalised); the suffix (default |
|
Value (or callable) shown by an unbound form. Never a validation fallback. |
|
A widget class or instance replacing the field’s default control. See Widgets: rendering and extracting. |
|
Descriptive text rendered with the field; since 5.0 inputs get an |
|
A dictionary overriding messages by key ( |
|
A list of callables run after the field’s own validation; see Form Validation & Validators. |
|
|
|
Renders the |
|
Template used by |
|
A per-field replacement for |
Source: Django: Core field arguments
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 |
|---|---|---|---|
|
|
|
With |
|
|
|
Three-state; the model |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Takes |
|
|
|
Accepts the usual textual UUID forms. |
|
|
|
|
|
|
|
|
|
|
|
Same as |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
One date and one time control combined; |
|
|
|
Accepts |
|
|
|
|
|
|
|
Adds |
|
|
|
Same |
|
|
|
|
|
|
a model instance |
|
|
|
|
Same arguments; the model-bound fields belong to Model Forms & Formsets. |
|
|
|
|
|
|
|
|
|
|
|
Needs Pillow; adds the |
|
|
any JSON value |
|
|
|
|
|
|
none (subclass supplies a |
whatever |
|
Source: Django: Built-in Field classes
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)
Source: Django: Specifying widgets
The built-in widgets, grouped as the reference groups them:
| Group | Widgets |
|---|---|
Text-like inputs |
|
Date and time |
|
Choices |
|
Files |
|
Composite and hidden |
|
Source: Django: Built-in widgets
Things that matter in practice:
-
attrsadds HTML attributes.forms.TextInput(attrs={"autofocus": True, "autocomplete": "off"})renders the attributes verbatim;Truerenders a bare attribute (autofocus). This is how you add a CSS class, aplaceholder,inputmodeortype="date"without touching templates. To set attributes in bulk (every field gets a class), loop overself.fieldsin the form’s__init__and updatewidget.attrs, or usebound_field_classor a template override (see the rendering page). -
Field type and widget type are independent.
DateField(widget=DateInput(attrs={"type": "date"}))still validates adate; replacing the widget does not change what is accepted. SwappingTextInputforHiddenInputdoes 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 aSelectDateWidgetit joinsname_year,name_month,name_day).value_omitted_from_data()tells the form whether the control was submitted at all;CheckboxInputanswersFalsealways, because an unticked box is indistinguishable from an omitted one, which is whyBooleanFieldcan never be "left alone" in a partial update. -
Required rendering. A widget decides through
use_required_attribute(initial)whetherrequiredis emitted; the built-inClearableFileInputdrops 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 getsaria-invalid="true"(5.0), and (5.2) the error list is connected througharia-describedbytoo;use_fieldsetcontrols whether multi-control widgets are wrapped in a<fieldset>with a<legend>. -
Writing a widget (a
template_name, aget_context()override, aMediaclass 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:
# 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"),
]
Source: Django: The view and django.shortcuts.redirect
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
elsebranch builds the unbound form, so there is exactly onerender()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
302to a GET URL makes refresh harmless and the success page bookmarkable. Useredirect()orHttpResponseRedirectwith a named URL throughreverse(); never hard-code paths. A303 See Otheris the status that says "fetch this with GET" most precisely; Django’s default302is 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, not400or422; browsers show it either way, but many tools treat a4xxpage as a failure. JavaScript clients that need a machine-readable signal use a JSON response, assuggest_apibelow does with422. -
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. -
novalidateis 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 withcleaned_data, never withrequest.POST. Work that must not run if a transaction rolls back belongs intransaction.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.
# 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>
Source: Django: Building a form and HttpRequest.GET
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().
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
Source: Django: FormView
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:
-
A random secret is stored in the
csrftokencookie (changed at login). -
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). -
For every request whose method is not GET, HEAD, OPTIONS or TRACE the middleware requires the cookie and a matching token, from the
csrfmiddlewaretokenform field or from theX-CSRFTokenheader (CSRF_HEADER_NAME). -
It also checks the
Originheader against the current host andCSRF_TRUSTED_ORIGINS, and, for HTTPS requests with noOrigin, theRefererstrictly. On failure the response is a 403, produced by theCSRF_FAILURE_VIEW(django.views.csrf.csrf_failureby default) and logged on thedjango.security.csrflogger.
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
actionis an external site: the token would be leaked to that site. -
The template must be rendered with a
RequestContext, whichrender(), the generic views and the contrib apps already do. If you build the response withTemplate.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: Cookieand sets a cookie, so it interacts with caching; the how-to explains thecache_page/ensure_csrf_cookiecombinations (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.
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());
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")
Source: Django: CSRF with AJAX and Form.errors.get_json_data()
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 |
|---|---|
|
Cookie name ( |
|
|
|
Store the secret in the session instead of a cookie. |
|
The |
|
Origins allowed to post cross-origin or behind a reverse proxy; each entry must include the scheme ( |
|
A function |
|
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. |
|
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". |
|
Makes |
|
Forces the cookie to be sent by a view that has no |
Source: Django: CSRF settings and CSRF utilities
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".
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)
# 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>
Source: Django: File uploads and forms.FileField
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">
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")
Source: forms.ImageField and Django: File uploads
What to know about file fields:
-
FileFieldcleans to anUploadedFile(InMemoryUploadedFilebelow 2.5 MB,TemporaryUploadedFileabove, controlled byFILE_UPLOAD_MAX_MEMORY_SIZE) withname,size,content_typeandread()/chunks(). The name is the client’s, reduced to a base name; it is still client data, which is why the view stores it throughdefault_storageand not throughopen(os.path.join(…)). The test of the view posts../../evil.txtand checks that the stored name isexcerpts/evil…insideMEDIA_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_typeare client claims, so serving uploaded files safely is a separate topic (File Uploads & Storage, including multiple files, upload handlers, storage backends and private files). -
ImageFieldneeds Pillow and opens the image to verify it (invalid_imagefor a corrupt or fake file), attaching the Pillow object as.imageon 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_lengthlimits the file name, not the size;allow_empty_file=Trueaccepts a zero-byte file (the default rejects it with theemptycode). 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) andDATA_UPLOAD_MAX_NUMBER_FIELDS(1000), and a limit at the reverse proxy. -
Widgets.
ClearableFileInputis the default; when the form has aninitialfile (an edit form) it shows the current file and a "Clear" checkbox for optional fields.FileInputis the plain control. Neither acceptsmultipleunless the widget class setsallow_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 customFileFieldsubclass as well. -
Request-cycle position. Because
CsrfViewMiddlewarereadsrequest.POSTfor 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 bycsrf_protectinside, 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"})
Source: Django: Field.disabled and Dynamic initial values
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=Trueignores the client. The attacker’sowner=malloryis discarded and the initial valueanais what ends up incleaned_data. This is the correct way to show a read-only value; areadonlyHTML attribute alone is only a hint to the browser. Combine it with aclean_<field>hook if the value must be re-checked. -
Extra constructor arguments go through
__init__. Take them as keyword-only parameters before callingsuper().init()and never as a positional argument that shiftsdata. The common case isrequest.useror a queryset limited to the current user; aFormViewsupplies it throughget_form_kwargs(). -
Per-instance changes.
self.fieldsis a per-instance deep copy, so modifying a field’schoices,queryset,initialorwidget.attrsin__init__is safe. Changing the class-level field (ShelfForm.base_fields) at request time is not: it is shared by every request. -
TypedChoiceFieldconverts. The blank option cleans toNonethroughempty_value, and"50"cleans to50.
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">
Source: Django: Rendering fields manually and BoundField
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.
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"))
Source: SimpleTestCase.assertFormError() and assertRedirects()
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,FileFieldwith validators that read bytes) can be validated directly inside an async view; the work is CPU-bound and short.request.POSTcan also be read in an async view, because the ASGI handler has already buffered the body. -
ModelChoiceField,ModelMultipleChoiceField,uniquechecks in aModelForm, and anyclean_<field>hook that runs a query evaluate a queryset in the synchronous API. Called from the event loop, Django raisesSynchronousOnlyOperation. Run the whole validation in a thread withsync_to_async()(orasyncio.to_thread()).
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())
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")
Source: Django: Asynchronous support and ModelChoiceField
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; userequest.method == "POST". -
Forgetting
request.FILESorenctype. The file is silently absent; the field says "This field is required". -
@csrf_exemptto cure a 403. It removes the protection. Add{% csrf_token %}, render with a request, or fixCSRF_TRUSTED_ORIGINS. -
{% csrf_token %}in a form with an externalaction. 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 ofcleaned_data. You skip validation, conversion and normalisation. -
Reading
cleaned_dataafter a failedis_valid(). Keys of failed fields are missing. -
BooleanFieldwithoutrequired=False. The checkbox becomes mandatory. -
Believing
initialis a default. It is only for display; a missing value fails "required". -
Treating
HiddenInputorreadonlyas trusted. Usedisabled=Trueand validate on the server; a hidden control is user input. -
Passing
request.POSTto a form behind a JSON API. A JSON body leavesrequest.POSTempty. -
Mutating the class-level fields at request time. Change
self.fieldsin__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 aModelChoiceField, or set the choices in__init__. -
Mixing a validation concern into the template. Show
form.errors,non_field_errorsand each field’s errors, or users will not see why the page came back. -
fields = "all"andexcludein model forms. Mass assignment: always list the fields explicitly (see Model Forms & Formsets). -
Trusting the browser’s
requiredandmaxlength. They are hints; every rule must also exist on the server. -
Validating a
ModelChoiceFieldform in an async view withoutsync_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_divarrived in 4.1 and became the default for{{ form }}in 5.0, andas_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
CsrfExemptMixinor@csrf_exemptto make a JavaScript POST work are wrong; the documented answer is theX-CSRFTokenheader (JavaScript and fetch). -
CSRF_TRUSTED_ORIGINSwithout a scheme. Entries must includehttps://since 4.0; the old bare host names make the system check fail. -
ugettext/ugettext_lazyfor labels and error messages. Removed in 4.0; usegettext_lazy. -
url()inform actionreverse examples.django.conf.urls.url()was removed in 4.0; usepath()/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. RaisesValueErrorsince 4.2.1 (4.1.9, 3.2.19); see File fields. -
URLFielddefaulting tohttp. The default scheme for a value typed without one changed tohttpsin 6.0 (theassume_schemeargument came in 5.0, and theFORMS_URLFIELD_ASSUME_HTTPStransitional setting is gone). -
ModelChoiceFieldblank label---------. It is- Select an option -in 6.1. -
Choices as a hand-built list of tuples only.
ChoiceFieldalso accepts an enumeration, a mapping or a callable (5.0), which is much tidier withTextChoices(3.0). -
render_to_response()(removed 3.0) andrequest.REQUEST(long gone) in form views; userender()andrequest.POSTorrequest.GET. -
django.shortcuts.render_to_responseandModelFormwithoutMeta.fields. The latter has raisedImproperlyConfiguredsince 1.8; the books' habit offields = "all"still works and is a security flaw (mass assignment). -
jQuery date pickers and Bootstrap 3/4 markup. Today
<input type="date">(viaDateInput(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; |
4.1 |
|
4.2 |
|
5.0 |
|
5.1 |
The form fieldset gets an |
5.2 |
|
6.0 |
|
6.1 |
|
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
Where this fits in the section
The book and the official-documentation material behind this page are listed in the section bibliography. The pages that continue the topic are:
-
Form Validation & Validators: the validation cycle,
clean(),add_error()and validators. -
Form Rendering & Widgets: the rendering API, field groups, custom widgets and
Media. -
Model Forms & Formsets:
ModelForm,save(commit=False), formsets. -
File Uploads & Storage: upload handlers, storages, multiple files, serving files.
-
Security: the full security checklist around CSRF, XSS and clickjacking.
-
Templates: the template language the forms render with.