ZetCode

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.

config/settings.py
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.

config/urls.py
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.

feedback/forms.py (fields)
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.

demos/basics.py
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.

feedback/forms.py (validation methods)
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.

demos/validation.py
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.

feedback/views.py (function view)
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).

feedback/urls.py
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.

feedback/templates/feedback/thanks.html
{% 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.

feedback/templates/feedback/base.html
<!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.

feedback/templates/feedback/form.html
{% 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 }}.

feedback/templates/feedback/manual.html
{% 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.

demos/client.py
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.

feedback/views.py (FormView)
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.

feedback/tests.py
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

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Django tutorials.