Django forms
last modified November 13, 2026 Python 3.15
In this article we work with HTML forms in Django. We define a form class with fields and widgets, validate the submitted data and read the cleaned values, add our own validation rules, render the form in a template, process it in a function-based and a class-based view, and see how the CSRF token protects it. The examples use Django 6.1 and Python 3.15.
Django forms
An HTML form sends data as text, and everything that arrives in a request is untrusted.
The Django forms library handles the usual tasks in one place: a form class
declares the fields, renders them as HTML, validates the submitted data, and converts the
valid values to Python types. A form is unbound when it has no data, and it is
then used to display an empty or initial form. It is bound when it is created with
submitted data; only a bound form can be validated. The method is_valid() runs
the validation, and afterwards the dictionary cleaned_data holds the converted
values and errors holds the messages for the invalid ones. A form class that is not
connected to a database model derives from forms.Form; forms built from models
are the topic of the next article.
The example project
The project is created with uv; the steps are explained in
Django introduction. The commands below create a project
directory, install Django, generate the project with the configuration package
config, start the app feedback, and create the folder demos for demo scripts.
$ mkdir forms $ cd forms $ uv init --bare $ uv add django $ uv run django-admin startproject config . $ uv run python manage.py startapp feedback $ mkdir demos
The trailing dot in startproject config . creates manage.py and the
config package in the current directory instead of one level deeper. A new app is
ignored by Django until it is listed in INSTALLED_APPS, so we add
the app to the list in config/settings.py; the other
entries are generated by startproject.
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'feedback',
]
The listings in this article are of two kinds. A listing with a solid blue border and the label
Project file is a file of the application: models, views, templates, tests, and
settings. A listing with a dashed amber border and the label Demo script is a small
helper in the demos folder; it sets up Django, runs a few lines of code, and prints
the results, so that we can watch the behaviour. Demo scripts are not part of the application;
we run one from the project directory with uv run python -m demos.name.
The project is a feedback page: the app feedback holds the form, the views, and the
templates.
The root URL configuration includes the URLs of the app, which we define later together with the views.
from django.urls import include, path
urlpatterns = [
path('', include('feedback.urls')),
]
The empty prefix means that the pages of the app are served from the root of the site.
Defining a form
A form is a class whose attributes are fields. Each field type validates and converts a
kind of value: CharField gives a string, EmailField a string that looks
like an email address, IntegerField an int, BooleanField a
bool, and ChoiceField one of the listed choices. Other common fields are
DecimalField, DateField, DateTimeField,
FileField, and MultipleChoiceField.
from django import forms
TOPICS = [('question', 'Question'), ('bug', 'Bug report'), ('idea', 'Idea')]
class FeedbackForm(forms.Form):
name = forms.CharField(
max_length=50,
widget=forms.TextInput(attrs={'placeholder': 'Your name', 'autocomplete': 'name'}))
email = forms.EmailField(help_text='We never share it.')
topic = forms.ChoiceField(choices=TOPICS, initial='question')
rating = forms.IntegerField(min_value=1, max_value=5, required=False, help_text='1 to 5')
message = forms.CharField(
min_length=10,
widget=forms.Textarea(attrs={'rows': 4}),
error_messages={'required': 'Please write a message.'})
subscribe = forms.BooleanField(required=False, label='Send me news')
Every field is required unless we pass required=False; the checkbox and the
rating are optional. The arguments max_length, min_length,
min_value, and max_value add validators, and the browser gets the same limits as HTML
attributes. The label is the text of the HTML label; Django derives it from the field name when
we do not give one. The help_text is a hint that is rendered next to the field, initial is the value of an
unbound form, and error_messages replaces the default message of an error, here the message of the
required error of the message. A widget controls the HTML element of a field: a
CharField renders a text input by default, and we pass Textarea to get a
multi-line input. The attrs argument adds HTML attributes to the element, such as the placeholder or CSS
classes.
Bound and unbound forms
The next script creates the form twice. The first form has no data, so it is unbound and
useful only for rendering. The second form gets a dictionary of strings, which is what the
browser sends and what request.POST contains.
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
from feedback.forms import FeedbackForm
form = FeedbackForm()
print('unbound:', form.is_bound, form.is_valid())
print('initial topic:', form['topic'].initial)
data = {
'name': ' alice smith ',
'email': 'Alice@Example.com',
'topic': 'idea',
'rating': '5',
'message': 'Please add a dark theme.',
'subscribe': 'on',
}
form = FeedbackForm(data)
print('bound:', form.is_bound, form.is_valid())
for name, value in form.cleaned_data.items():
print(f' {name}: {value!r}')
print('errors:', dict(form.errors))
An unbound form is never valid, and the initial value of the topic is available from its
field. The bound form is valid, and cleaned_data contains the converted values: the
rating is an int, the checkbox value on became True, the
spaces around the name are removed, and the omitted optional fields would be None or False. The name and the email also
show the result of the custom methods that we write in the next section; the name was converted to the title case and the
email to the lower case.
$ uv run python -m demos.basics
unbound: False False
initial topic: question
bound: True True
name: 'Alice Smith'
email: 'alice@example.com'
topic: 'idea'
rating: 5
message: 'Please add a dark theme.'
subscribe: True
errors: {}
Validation
Django validates a form in steps. First, each field cleans its value: it converts the string to the Python
type and runs its validators. Then, for each field, a method clean_<fieldname>(),
if the form defines it, validates and may change the value. At the end, the method clean()
validates the form as a whole. A method reports a problem by raising
forms.ValidationError, and the message is stored in form.errors under the field name. Errors raised in
clean() belong to the form and are stored under __all__; with
add_error('field', message) we attach one to a field. The methods must return the
cleaned value, and clean() the cleaned_data dictionary.
class FeedbackForm(forms.Form):
...
def clean_name(self):
name = self.cleaned_data['name'].strip()
if any(char.isdigit() for char in name):
raise forms.ValidationError('The name must not contain digits.')
return name.title()
def clean_email(self):
email = self.cleaned_data['email'].lower()
if email.endswith('@mailinator.com'):
raise forms.ValidationError('Disposable addresses are not accepted.')
return email
def clean(self):
cleaned_data = super().clean()
message = cleaned_data.get('message')
if cleaned_data.get('topic') == 'bug' and message is not None and len(message) < 30:
self.add_error('message', 'Please describe the bug in at least 30 characters.')
return cleaned_data
The method clean_name() rejects names that contain digits and returns the name in the title
case. The method clean_email() normalizes the address and rejects a disposable domain.
In clean() we compare two fields: a bug report needs a longer message. Fields that failed their own
validation are missing from cleaned_data, so the method uses get() and checks
for None before using the message.
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
from feedback.forms import FeedbackForm
good = {'name': 'Alice', 'email': 'alice@example.com', 'topic': 'question',
'message': 'How do I reset my password?'}
cases = {
'empty': {},
'bad values': {**good, 'name': 'R2D2', 'email': 'not-an-email', 'rating': '9',
'topic': 'rant'},
'short message': {**good, 'message': 'Too short'},
'disposable': {**good, 'email': 'bob@mailinator.com'},
'terse bug report': {**good, 'topic': 'bug', 'message': 'It does not work.'},
}
for title, data in cases.items():
form = FeedbackForm(data)
print(f'{title}: valid={form.is_valid()}')
for field, errors in form.errors.items():
print(f' {field}: {list(errors)}')
The script submits five data sets. The empty one shows the required errors, including our own message
for the message field. The second one shows the built-in errors of the email, the choice, and the rating, together with our
check of the name. The remaining three trigger the length validator of the message field, the
clean_email() method, and the clean() method. Note that all errors of a form are
collected, so the user sees every problem at once.
$ uv run python -m demos.validation empty: valid=False name: ['This field is required.'] email: ['This field is required.'] topic: ['This field is required.'] message: ['Please write a message.'] bad values: valid=False name: ['The name must not contain digits.'] email: ['Enter a valid email address.'] topic: ['Select a valid choice. rant is not one of the available choices.'] rating: ['Ensure this value is less than or equal to 5.'] short message: valid=False message: ['Ensure this value has at least 10 characters (it has 9).'] disposable: valid=False email: ['Disposable addresses are not accepted.'] terse bug report: valid=False message: ['Please describe the bug in at least 30 characters.']
Processing a form in a view
A view that handles a form follows a pattern. A GET request shows an unbound form. A
POST request creates a bound form from request.POST; if it is valid, the view
processes the data and redirects, and if it is not, the view renders the same bound form again, now with
the errors and the values that the user has typed. Redirecting after a successful POST is known as the
Post/Redirect/Get pattern; it prevents the browser from submitting the form again when the user reloads the
page. Our view sends the feedback by email (see Django email) and redirects to a thank-you
page.
from django.core.mail import send_mail
from django.shortcuts import redirect, render
from django.urls import reverse_lazy
from django.views.generic import FormView
from .forms import FeedbackForm
def send_feedback(data):
send_mail(
f'Feedback: {data["topic"]}',
f'From {data["name"]} <{data["email"]}>\n\n{data["message"]}',
'site@example.com', ['team@example.com'])
def feedback(request, template='feedback/form.html'):
if request.method == 'POST':
form = FeedbackForm(request.POST)
if form.is_valid():
send_feedback(form.cleaned_data)
return redirect('feedback:thanks')
else:
form = FeedbackForm()
return render(request, template, {'form': form})
The URL manual/ uses the same view with another template, which is passed as an extra
argument from the URL configuration (see Django URL routing).
from django.urls import path
from . import views
app_name = 'feedback'
urlpatterns = [
path('', views.feedback, name='form'),
path('manual/', views.feedback, {'template': 'feedback/manual.html'}, name='manual'),
path('cbv/', views.FeedbackView.as_view(), name='cbv'),
path('thanks/', views.thanks, name='thanks'),
]
The name of the application is feedback, so the redirect uses the name
feedback:thanks. The view for the URL cbv/ is the class-based variant
that we write later. The redirect leads to a page that only says thanks; it extends the base template
that we write in the next section.
{% extends 'feedback/base.html' %}
{% block heading %}Thank you{% endblock %}
{% block content %}
<p>Your feedback was sent.</p>
{% endblock %}
The template fills the two blocks that the base template defines: the heading and the content.
Rendering a form
A form renders itself when it is used in a template. We put it inside our own
<form> element, because Django renders only the fields; the element, the submit button,
and the CSRF token are our responsibility. The template extends a base template as described in
Django templates.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{% block title %}Feedback{% endblock %}</title>
</head>
<body>
<h1>{% block heading %}Feedback{% endblock %}</h1>
{% block content %}{% endblock %}
</body>
</html>
The base template defines the blocks for the title, the heading, and the content. The page of the form fills the content block.
{% extends 'feedback/base.html' %}
{% block content %}
<form method="post">
{% csrf_token %}
{{ form }}
<button type="submit">Send</button>
</form>
{% endblock %}
The expression {{ form }} renders all fields, each in a div with its label, the
help text, the errors, and the input. Since Django 5.0 this is the default layout, which is the same as
form.as_div; the methods as_p, as_ul, and as_table give other
layouts. Django generates the id attributes, the labels point to them, the
help text is connected to the input with aria-describedby, and the validators of the fields became
the attributes required, maxlength, min and max. The
output was taken from the running development server, with the empty lines removed.
$ curl -s http://127.0.0.1:8000/ | sed -n '/<form/,/<\/form>/p' | grep -v '^\s*$'
<form method="post">
<input type="hidden" name="csrfmiddlewaretoken" value="h5YQCFsXnvh4CSeXOGBsaCzfRqqocFySwHFJZQ7AsXsQGErNL6gtkRJ9mX2GpJVU">
<div>
<label for="id_name">Name:</label>
<input type="text" name="name" placeholder="Your name" autocomplete="name" maxlength="50" required id="id_name">
</div>
<div>
<label for="id_email">Email:</label>
<div class="helptext" id="id_email_helptext">We never share it.</div>
<input type="email" name="email" maxlength="320" required aria-describedby="id_email_helptext" id="id_email">
</div>
<div>
<label for="id_topic">Topic:</label>
<select name="topic" id="id_topic">
<option value="question" selected>Question</option>
<option value="bug">Bug report</option>
<option value="idea">Idea</option>
</select>
</div>
<div>
<label for="id_rating">Rating:</label>
<div class="helptext" id="id_rating_helptext">1 to 5</div>
<input type="number" name="rating" min="1" max="5" aria-describedby="id_rating_helptext" id="id_rating">
</div>
<div>
<label for="id_message">Message:</label>
<textarea name="message" cols="40" rows="4" minlength="10" required id="id_message">
</textarea>
</div>
<div>
<label for="id_subscribe">Send me news:</label>
<input type="checkbox" name="subscribe" id="id_subscribe">
</div>
<button type="submit">Send</button>
</form>
Rendering fields manually
When we need full control over the markup, for example to use the classes of a CSS framework, we loop over
the form and render the pieces ourselves. A field has the attributes label_tag, errors,
help_text, and id_for_label, and the field itself, {{ field }}, renders the widget.
The errors that are not tied to a field, such as those raised by clean() without
add_error(), are in form.non_field_errors. We can also address a single field by its name,
as in {{ form.email }}.
{% extends 'feedback/base.html' %}
{% block content %}
<form method="post" novalidate>
{% csrf_token %}
{{ form.non_field_errors }}
{% for field in form %}
<p>
{{ field.label_tag }}
{{ field }}
{% if field.help_text %}<small>{{ field.help_text }}</small>{% endif %}
{{ field.errors }}
</p>
{% endfor %}
<button type="submit">Send</button>
</form>
{% endblock %}
The template renders a paragraph for each field: the label, the widget, the help text in a
small element, and the errors, which are empty for an unbound form. The attribute
novalidate of the form turns off the validation in the browser, so that we can see the
validation of Django when we try the page. We request the page manual/.
$ curl -s http://127.0.0.1:8000/manual/ | sed -n '/<form/,/<\/form>/p' | grep -v '^\s*$'
<form method="post" novalidate>
<input type="hidden" name="csrfmiddlewaretoken" value="knhLjTRrYIbKyADkBV31JiuxQ7JHoKmqOI631dzyuiJOgnxX7nn1SAM3bJ2r61KS">
<p>
<label for="id_name">Name:</label>
<input type="text" name="name" placeholder="Your name" autocomplete="name" maxlength="50" required id="id_name">
</p>
<p>
<label for="id_email">Email:</label>
<input type="email" name="email" maxlength="320" required aria-describedby="id_email_helptext" id="id_email">
<small>We never share it.</small>
</p>
<p>
<label for="id_topic">Topic:</label>
<select name="topic" id="id_topic">
<option value="question" selected>Question</option>
<option value="bug">Bug report</option>
<option value="idea">Idea</option>
</select>
</p>
<p>
<label for="id_rating">Rating:</label>
<input type="number" name="rating" min="1" max="5" aria-describedby="id_rating_helptext" id="id_rating">
<small>1 to 5</small>
</p>
<p>
<label for="id_message">Message:</label>
<textarea name="message" cols="40" rows="4" minlength="10" required id="id_message">
</textarea>
</p>
<p>
<label for="id_subscribe">Send me news:</label>
<input type="checkbox" name="subscribe" id="id_subscribe">
</p>
<button type="submit">Send</button>
</form>
CSRF protection
Cross-site request forgery is an attack in which another site makes the browser of a logged-in user submit
a form to our site. Django defends against it with the CsrfViewMiddleware, which is in the
MIDDLEWARE setting from the start. The middleware sets a cookie with a secret, and the tag
{% csrf_token %} places a hidden field with a token derived from it into the form, which we saw as
csrfmiddlewaretoken in the rendered output. A POST request whose token does not match the cookie is rejected with the status
403. A foreign site can make the browser send the cookie, but it cannot read the token to put it into the
request. The tag is needed in every form that uses POST to a URL of our site; we must never exempt a view
from the check only because the check is inconvenient.
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
import re
from django.test import Client
from django.test.utils import setup_test_environment
setup_test_environment()
client = Client(enforce_csrf_checks=True)
data = {'name': 'alice', 'email': 'alice@example.com', 'topic': 'question',
'message': 'How do I reset my password?'}
response = client.post('/', data)
print('POST without a token:', response.status_code)
client.get('/')
data['csrfmiddlewaretoken'] = client.cookies['csrftoken'].value
response = client.post('/', {**data, 'message': 'short'})
print('POST with an invalid message:', response.status_code)
print(response.context['form'].errors.as_text())
html = response.content.decode()
print(re.search(r'<ul class="errorlist.*?</ul>', html, re.S).group(0))
response = client.post('/', data)
print('POST with valid data:', response.status_code, response['Location'])
The first command sends a POST request without any token to the running server, and the server answers
with 403. The script uses the Django test client, which skips the CSRF check unless we create it with
enforce_csrf_checks=True. It posts the form without the token, which fails; it then loads the page,
which sets the cookie, and sends the token from the cookie in the field csrfmiddlewaretoken. A post with a
short message returns the form (status 200) with the error list that is rendered next to the field; a valid post is answered
with a redirect to the thank-you page. The last line, which Django writes to the standard error stream, is the log
message of the rejected first request.
$ curl -s -o /dev/null -w '%{http_code}\n' -X POST -d 'name=Alice' http://127.0.0.1:8000/
403
$ uv run python -m demos.client
POST without a token: 403
POST with an invalid message: 200
* message
* Ensure this value has at least 10 characters (it has 5).
<ul class="errorlist" id="id_message_error"><li>Ensure this value has at least 10 characters (it has 5).</li></ul>
POST with valid data: 302 /thanks/
Forbidden (CSRF cookie not set.): /
Class-based FormView
The class-based views of Django class-based views include
FormView, which implements the pattern of the function view. It shows an
unbound form on GET and a bound one on POST; if the form is valid, it calls form_valid(), and its default
implementation redirects to success_url; otherwise it renders the form with the errors. We set the
template, the form class, and the URL, and write only what is specific: sending the message.
from django.urls import reverse_lazy
from django.views.generic import FormView
...
class FeedbackView(FormView):
template_name = 'feedback/form.html'
form_class = FeedbackForm
success_url = reverse_lazy('feedback:thanks')
def form_valid(self, form):
send_feedback(form.cleaned_data)
return super().form_valid(form)
The attribute success_url is evaluated when the module is imported, so we use
reverse_lazy(), which resolves the name later. The method form_valid() sends the email and calls the
parent method to get the redirect. The view uses the same template and gives the same results as the function view; when a
form is shown on a page that does more than just that form, the function view is easier to adapt.
Testing forms
Forms are easy to test because they do not need a server: we create a form with a dictionary and check
is_valid(), cleaned_data, and errors. The tests of the views use the
test client, which also records the sent email in mail.outbox; a client created with
enforce_csrf_checks=True checks that the token is required. See
Django views for the other uses of the test client.
from django.core import mail
from django.test import TestCase
from django.urls import reverse
from .forms import FeedbackForm
VALID = {'name': 'alice', 'email': 'Alice@Example.com', 'topic': 'question',
'message': 'How do I reset my password?'}
class FormTests(TestCase):
def test_valid_data_is_cleaned(self):
form = FeedbackForm(VALID)
self.assertTrue(form.is_valid())
self.assertEqual(form.cleaned_data['name'], 'Alice')
self.assertEqual(form.cleaned_data['email'], 'alice@example.com')
self.assertIsNone(form.cleaned_data['rating'])
self.assertFalse(form.cleaned_data['subscribe'])
def test_name_with_digits_is_rejected(self):
form = FeedbackForm({**VALID, 'name': 'R2D2'})
self.assertFalse(form.is_valid())
self.assertEqual(form.errors['name'], ['The name must not contain digits.'])
def test_rating_is_limited(self):
form = FeedbackForm({**VALID, 'rating': '6'})
self.assertIn('rating', form.errors)
def test_bug_report_needs_a_longer_message(self):
form = FeedbackForm({**VALID, 'topic': 'bug'})
self.assertEqual(form.errors['message'],
['Please describe the bug in at least 30 characters.'])
class ViewTests(TestCase):
def test_get_shows_an_unbound_form(self):
response = self.client.get(reverse('feedback:form'))
self.assertContains(response, 'csrfmiddlewaretoken')
self.assertFalse(response.context['form'].is_bound)
def test_invalid_post_redisplays_the_form(self):
response = self.client.post(reverse('feedback:form'), {**VALID, 'name': ''})
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'This field is required.')
self.assertEqual(len(mail.outbox), 0)
def test_valid_post_sends_mail_and_redirects(self):
response = self.client.post(reverse('feedback:form'), VALID)
self.assertRedirects(response, reverse('feedback:thanks'))
self.assertEqual(len(mail.outbox), 1)
self.assertIn('Alice <alice@example.com>', mail.outbox[0].body)
def test_form_view_works_like_the_function_view(self):
response = self.client.post(reverse('feedback:cbv'), VALID)
self.assertRedirects(response, reverse('feedback:thanks'))
self.assertEqual(len(mail.outbox), 1)
def test_csrf_token_is_required(self):
from django.test import Client
client = Client(enforce_csrf_checks=True)
response = client.post(reverse('feedback:form'), VALID)
self.assertEqual(response.status_code, 403)
The form tests cover the cleaning of the values, the custom validation methods, the built-in limit of the rating, and
the cross-field rule. The view tests check that the GET request gives an unbound form with a CSRF token, that
an invalid POST re-renders the form and sends no email, that a valid POST sends one email and redirects, that the
FormView behaves the same, and that a POST without a token is rejected.
$ uv run python manage.py test Found 9 test(s). Creating test database for alias 'default'... System check identified no issues (0 silenced). ......... ---------------------------------------------------------------------- Ran 9 tests in 0.043s OK Destroying test database for alias 'default'...
Source
Django documentation: Working with forms
Django documentation: Form fields
Django documentation: Form and field validation
Django documentation: How to use Django's CSRF protection
In this article we have defined a Django form with fields and widgets, validated it with built-in
validators, clean_<field>(), and clean(), rendered it automatically and manually, processed it in a
function view and a FormView, protected it with the CSRF token, and tested it. Related articles are
Django views, Django templates, and
Django model validation and signals.
Author
List all Django tutorials.