EthicsComputerCourses
Join training +91 90532 10052

Forms & ModelForms

Django & Flask · Lesson 20 of 42 Intermediate 7 min read Updated

In Forms & Request Data you validated an order form by hand: read each value, convert it, check it, collect errors, re-show the form. Django's forms library does all of that for you. You describe the fields once in a class, and Django renders the HTML, converts the submitted strings into Python values, validates them, and remembers the errors and the user's input for redisplay.

In this lesson you will build a "Suggest a book" form for the Jaipur City Library, add custom validation, write the standard view pattern, protect it with a CSRF token, and then use a ModelForm to add and edit Book rows with almost no code.

A Form class

Python
# catalog/forms.py
from datetime import date

from django import forms

from .models import Book


class SuggestionForm(forms.Form):
    name = forms.CharField(max_length=100)
    email = forms.EmailField()
    title = forms.CharField(label="Book title", max_length=200)
    year = forms.IntegerField(min_value=1800, required=False)
    reason = forms.CharField(widget=forms.Textarea(attrs={"rows": 3}), max_length=500)

    def clean_year(self):
        year = self.cleaned_data["year"]
        if year and year > date.today().year:
            raise forms.ValidationError("That year is in the future.")
        return year

    def clean(self):
        cleaned = super().clean()
        title = cleaned.get("title", "")
        if title and Book.objects.filter(title__iexact=title).exists():
            self.add_error("title", "We already have this book.")
        return cleaned

Each field knows how to convert and check its value. EmailField checks the format, IntegerField converts to int and checks min_value, and every field is required unless you write required=False. The widget decides the HTML: Textarea instead of a single-line input.

Validation runs in a fixed order, and you can hook into it:

  1. Each field's own checks (type, length, required).
  2. clean_<fieldname>() methods for extra checks on one field. Return the cleaned value.
  3. clean() for checks that involve several fields or the database. Use self.add_error(field, message) to attach an error to a field.

Try it in the shell:

Python
form = SuggestionForm(data={"name": "Rohan", "email": "rohan@", "title": "Godaan",
                            "year": "2031", "reason": ""})
print(form.is_valid())
for field, errors in form.errors.items():
    print(field, "->", errors[0])

form = SuggestionForm(data={"name": "Rohan", "email": "rohan@example.com",
                            "title": "Pinjar", "year": "1950", "reason": "Classic Punjabi novel"})
print(form.is_valid(), form.cleaned_data["year"], type(form.cleaned_data["year"]).__name__)
Output
False
email -> Enter a valid email address.
year -> That year is in the future.
reason -> This field is required.
title -> We already have this book.
True 1950 int

is_valid() runs everything and returns True only when there are no errors. After that, read values from form.cleaned_data, never from request.POST: cleaned values are converted ("1950" became the integer 1950) and checked.

The view pattern

Almost every Django form view has this shape:

Python
# catalog/views_forms.py
from django.contrib import messages
from django.shortcuts import redirect, render

from .forms import SuggestionForm


def suggest(request):
    if request.method == "POST":
        form = SuggestionForm(request.POST)
        if form.is_valid():
            data = form.cleaned_data
            # In a real app: save it, or email the librarian.
            messages.success(request, f"Thanks {data['name']}, we will look for {data['title']}.")
            return redirect("catalog:suggest")
    else:
        form = SuggestionForm()
    return render(request, "catalog/suggest.html", {"form": form})

On GET, show an empty form. On POST, fill the form with request.POST; if it is valid, act and redirect (Post/Redirect/Get); if not, fall through and render the same form, which now carries the errors and the user's input. The messages framework (django.contrib.messages) works like Flask's flash(): a message is stored now and shown once on the next page.

Rendering the form and CSRF

HTML
<!-- catalog/templates/catalog/suggest.html -->
{% extends "base.html" %}
{% block content %}
{% for message in messages %}<p class="{{ message.tags }}">{{ message }}</p>{% endfor %}
<h1>Suggest a book</h1>
<form method="post">
  {% csrf_token %}
  {{ form }}
  <button type="submit">Send suggestion</button>
</form>
{% endblock %}

{{ form }} renders every field with its label, errors and current value, wrapped in <div> elements. For more control, render one field at a time with {{ form.email.as_field_group }} (Django 5.0+). After an invalid submit, that field group looks like this:

Output
<label for="id_email">Email:</label>
<ul class="errorlist" id="id_email_error"><li>Enter a valid email address.</li></ul>
<input type="email" name="email" value="rohan@" maxlength="320" required aria-invalid="true" aria-describedby="id_email_error" id="id_email">

Django even adds aria-invalid and aria-describedby, so screen readers announce the error. The user's input rohan@ is kept in the box.

{% csrf_token %} inserts a hidden field with a secret token. Django's CsrfViewMiddleware rejects any POST without a valid token with 403 Forbidden. Posting the form from a test client that enforces CSRF checks, without the token, gives exactly that status: 403. This blocks other websites from submitting forms on behalf of your logged-in users; Security explains the attack.

ModelForm: forms from models

Most forms create or edit a model, and repeating every field would be boring and error-prone. A ModelForm builds its fields from the model, including validation such as max_length, unique and choices:

Python
class BookForm(forms.ModelForm):
    class Meta:
        model = Book
        fields = ["title", "slug", "author", "language", "year", "price", "copies", "genres"]
        widgets = {"genres": forms.CheckboxSelectMultiple}

    def clean_slug(self):
        return self.cleaned_data["slug"].lower()

    def clean_price(self):
        price = self.cleaned_data["price"]
        if price <= 0:
            raise forms.ValidationError("Price must be more than zero.")
        return price

One view handles both adding and editing. instance=book fills the form with an existing row; form.save() inserts or updates and returns the object:

Python
def book_edit(request, slug=None):
    book = get_object_or_404(Book, slug=slug) if slug else None
    form = BookForm(request.POST or None, instance=book)
    if request.method == "POST" and form.is_valid():
        book = form.save()
        messages.success(request, f"Saved {book.title}.")
        return redirect(book)
    return render(request, "catalog/book_form.html", {"form": form, "book": book})

Posting a new book with slug PINJAR and the author Amrita Pritam redirects to /books/pinjar/ (the slug was lower-cased by clean_slug(), and redirect(book) used get_absolute_url()). Editing it with a price of -5 re-shows the form with Price must be more than zero.

Always list fields explicitly. If you used fields = "__all__", adding a field such as is_staff_pick to the model later would silently let any user set it through the form. When you need to set values the user must not control, save with commit=False:

Python
book = form.save(commit=False)
book.available = book.copies      # set by code, not by the user
book.save()
form.save_m2m()                   # needed for many-to-many fields after commit=False

Common mistakes

  • Forgetting {% csrf_token %}. Every POST fails with 403 Forbidden (CSRF token missing.).
  • Reading request.POST after validation. Use form.cleaned_data, which is converted and checked.
  • Rendering only some fields. Errors on fields you did not render stay invisible, and the form seems to "do nothing". Render {{ form.non_field_errors }} and every field.
  • fields = "__all__" in a ModelForm. Users can set any field you add later; list fields explicitly.
  • Not redirecting after success. Refreshing then submits the form again.

Key points

  • A forms.Form class declares fields; is_valid() converts and validates, and cleaned_data holds the results.
  • Add checks with clean_<field>() for one field and clean() with add_error() for several fields.
  • The view pattern is: GET shows an empty form; POST validates, then redirects on success or re-renders with errors.
  • {% csrf_token %} is required in every POST form; {{ form }} and as_field_group render accessible HTML.
  • ModelForm builds fields from a model and save() creates or updates rows; use commit=False to set extra values.

Exercise

Create a MemberForm ModelForm for the Member model with the fields name, phone and city. Add clean_phone() that removes spaces and checks that the result is exactly 10 digits starting with 6, 7, 8 or 9 (Indian mobile numbers), and clean_name() that turns the name into title case. Test it in the shell with a valid and an invalid phone number.

Show solution
Python
class MemberForm(forms.ModelForm):
    phone = forms.CharField(max_length=15)      # allow spaces in what people type

    class Meta:
        model = Member
        fields = ["name", "phone", "city"]

    def clean_phone(self):
        phone = self.cleaned_data["phone"].replace(" ", "")
        if not (len(phone) == 10 and phone.isdigit() and phone[0] in "6789"):
            raise forms.ValidationError("Enter a 10-digit Indian mobile number.")
        return phone

    def clean_name(self):
        return self.cleaned_data["name"].title()


form = MemberForm(data={"name": "meera joshi", "phone": "98290 12345", "city": "Jaipur"})
print(form.is_valid(), form.cleaned_data["name"], form.cleaned_data["phone"])
form = MemberForm(data={"name": "Farhan", "phone": "12345", "city": "Ajmer"})
print(form.is_valid(), form.errors["phone"][0])
Output
True Meera Joshi 9829012345
False Enter a 10-digit Indian mobile number.

The model's max_length=10 would reject "98290 12345" (11 characters) before clean_phone() even runs, so the form declares its own phone field with room for spaces. clean_phone() then removes the spaces, and the 10-digit result fits the model.

Quick Quiz

1. After form.is_valid() returns True, where should you read the values?

2. Which method checks two fields together, such as title and author?

3. A POST form has no {% csrf_token %}. What does Django return?

4. Why use form.save(commit=False)?

Learn Django & Flask with a mentor

Free lessons get you started. Our Python Programming training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call