Forms

Reviewed & published by Brayan K

A Django form is a Python class that defines a set of fields, renders them as HTML inputs, and validates the data a user submits — turning messy, untrusted request data into clean, typed Python values you can safely use.

Part of the free Django course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

In this lesson you'll define your own Form subclass, wire it into a view using the GET-versus-POST pattern, and add validation that catches bad input before it ever reaches your database.

What You'll Learn in This Lesson

1 Defining a Form

A Django form is a Python class that subclasses forms.Form. Each field you declare on the class becomes both an HTML input when the form is rendered and a validation rule when the form is processed.

The field type you pick decides how the value is rendered and what counts as valid:

A widget controls the HTML used to render a field. For example, a CharField normally renders a single-line input, but giving it widget=forms.Textarea renders a multi-line box instead.

Here is a real Django form declaration:

from django import forms

class ContactForm(forms.Form):
    name = forms.CharField(max_length=100)
    email = forms.EmailField()
    age = forms.IntegerField(min_value=13, max_value=120)
    subscribe = forms.BooleanField(required=False)
    message = forms.CharField(
        widget=forms.Textarea,
        max_length=500,
    )
# Django's forms.Field machinery is large, but the core idea is simple:
# each field knows its type and rules. Here's a tiny stand-in that mimics
# how a Form describes its fields so you can see the shape of it.

class Field:
    def __init__(self, kind, required=True, max_length=None):
        self.kind = kind
        self.required = required
        self.max_length = max_length

class ContactForm:
    name = Field("CharField", max_length=100)
    email = Field("EmailField")
    age = Field("IntegerField")
    subscribe = Field("BooleanField", required=False)

# Inspect the declared fields, just like Django does internally
for field_name in ("name", "email", "age", "subscribe"):
    field = getattr(ContactForm, field_name)
    flag = "required" if field.required else "optional"
    print(f"{field_name:10} -> {field.kind:14} ({flag})")

# ✅ Expected output:
# name       -> CharField      (required)
# email      -> EmailField     (required)
# age        -> IntegerField   (required)
# subscribe  -> BooleanField   (optional)

2 Handling a Form in a View

The same view usually handles both showing an empty form and processing a submitted one. The deciding factor is the HTTP method:

Calling form.is_valid() runs validation and, when it returns True, fills form.cleaned_data — a dictionary of converted, trusted Python values keyed by field name.

This is the canonical view pattern:

from django.shortcuts import render, redirect

def contact_view(request):
    if request.method == "POST":
        form = ContactForm(request.POST)   # bound form
        if form.is_valid():
            name = form.cleaned_data["name"]
            email = form.cleaned_data["email"]
            # ... send an email, save to the DB, etc.
            return redirect("thanks")
        # invalid: fall through and re-render the bound form with errors
    else:
        form = ContactForm()                # unbound, empty form
    return render(request, "contact.html", {"form": form})

The matching template renders the form and the CSRF token:

<form method="post">
    {% csrf_token %}
    {{ form.as_p }}
    <button type="submit">Send</button>
</form>

Notice the template uses {% csrf_token %} and {{ form.as_p }}. The as_p helper wraps each field in a paragraph; alternatives are as_table and as_ul.

# Let's simulate the GET-vs-POST branching with a fake request and a tiny
# form that validates a dict of input the way Django validates request.POST.

class SimpleForm:
    def __init__(self, data=None):
        self.data = data            # None => unbound, like an empty GET form
        self.cleaned_data = {}
        self.errors = {}

    def is_bound(self):
        return self.data is not None

    def is_valid(self):
        if not self.is_bound():
            return False
        name = (self.data.get("name") or "").strip()
        if not name:
            self.errors["name"] = "This field is required."
            return False
        self.cleaned_data["name"] = name
        return True

def contact_view(request):
    if request["method"] == "POST":
        form = SimpleForm(request.get("POST"))
        if form.is_valid():
            return f"Saved! Hello, {form.cleaned_data['name']}."
        return f"Invalid form, errors: {form.errors}"
    else:
        form = SimpleForm()         # unbound
        return "Rendering empty form (GET request)."

# Simulate a GET, then a good POST, then a bad POST
print(contact_view({"method": "GET"}))
print(contact_view({"method": "POST", "POST": {"name": "Ada"}}))
print(contact_view({"method": "POST", "POST": {"name": ""}}))

# ✅ Expected output:
# Rendering empty form (GET request).
# Saved! Hello, Ada.
# Invalid form, errors: {'name': 'This field is required.'}

3 Validation

Validation happens in layers. First, each field checks the basics — a required field must have a value, an EmailField must look like an email, an IntegerField must parse as a number. You can add reusable validators to a field, and you can write a clean_<field> method for custom, per-field logic.

A clean_<field> method reads the value from self.cleaned_data, raises forms.ValidationError if something is wrong, and must return the value so it stays in cleaned_data:

from django import forms
from django.core.validators import MinLengthValidator

class SignupForm(forms.Form):
    username = forms.CharField(
        max_length=30,
        validators=[MinLengthValidator(3)],  # reusable validator
    )
    age = forms.IntegerField(min_value=0)

    def clean_username(self):
        value = self.cleaned_data["username"]
        if value.lower() in ("admin", "root"):
            raise forms.ValidationError("That username is reserved.")
        return value          # ALWAYS return the cleaned value
# Here's a runnable model of how Django validates a field, applies a
# field validator, and then runs a custom clean_<field> method.

class ValidationError(Exception):
    pass

class SignupForm:
    RESERVED = ("admin", "root")

    def __init__(self, data):
        self.data = data
        self.cleaned_data = {}
        self.errors = {}

    def is_valid(self):
        # 1. required + type check for "username"
        username = (self.data.get("username") or "").strip()
        if not username:
            self.errors["username"] = "This field is required."
        elif len(username) < 3:                      # min-length validator
            self.errors["username"] = "Must be at least 3 characters."
        else:
            try:
                self.cleaned_data["username"] = self.clean_username(username)
            except ValidationError as exc:
                self.errors["username"] = str(exc)
        return not self.errors

    def clean_username(self, value):
        if value.lower() in self.RESERVED:
            raise ValidationError("That username is reserved.")
        return value          # return so it survives into cleaned_data

for sample in ({"username": "Ada"}, {"username": "ad"}, {"username": "admin"}):
    form = SignupForm(sample)
    if form.is_valid():
        print("OK  ->", form.cleaned_data)
    else:
        print("BAD ->", form.errors)

# ✅ Expected output:
# OK  -> {'username': 'Ada'}
# BAD -> {'username': 'Must be at least 3 characters.'}
# BAD -> {'username': 'That username is reserved.'}

🎯 Your Turn: Gate on Validation

Fill in the blanks so the view only reads cleaned_data after the form reports that it is valid.

# 🎯 YOUR TURN: replace the two ___ blanks.
# 1) call the method that runs validation
# 2) read the trusted value dictionary

class SimpleForm:
    def __init__(self, data):
        self.data = data
        self.cleaned_data = {}
    def is_valid(self):
        name = (self.data.get("name") or "").strip()
        if name:
            self.cleaned_data["name"] = name
            return True
        return False

def handle(data):
    form = SimpleForm(data)
    if form.___():                       # blank 1: the gatekeeper method
        return "Hello, " + form.___["name"]   # blank 2: the clean values dict
    return "Please enter your name."

print(handle({"name": "Grace"}))

# EXPECTED OUTPUT:
# Hello, Grace

Common Errors (And How to Fix Them)

❌ AttributeError: 'ContactForm' object has no attribute 'cleaned_data'

You read form.cleaned_data before calling form.is_valid(), so the dictionary was never populated.

✅ Fix: only access cleaned_data inside the if form.is_valid(): block.

❌ 403 Forbidden — CSRF verification failed

Your POST template is missing the CSRF token tag.

✅ Fix: add {% csrf_token %} just inside the <form method="post"> tag.

❌ Submitted form loses its errors and entered values

On an invalid POST you created a fresh empty form instead of re-rendering the bound one.

✅ Fix: render the same bound form object — built from request.POST — so Django shows the errors and the user's input.

📋 Quick Reference

Field / MethodWhat It Does
forms.CharField()Text input; max_length, min_length, widgets
forms.EmailField()Text that must be a valid email address
forms.IntegerField()Converts input to an int; min_value/max_value
forms.BooleanField()Checkbox; use required=False to allow unchecked
form.is_valid()Runs validation; returns True and fills cleaned_data
form.cleaned_dataDict of converted, trusted values (after is_valid())
clean_<field>()Custom per-field validation; must return the value

🏆 Mini Challenge: Validate a Registration

Build a tiny form that validates a registration payload: a username of at least 3 characters and an age that is a whole number of 18 or more. Collect every error rather than stopping at the first.

# 🏆 Mini Challenge: validate username + age, collecting all errors.

class RegistrationForm:
    def __init__(self, data):
        self.data = data
        self.cleaned_data = {}
        self.errors = {}

    def is_valid(self):
        username = (self.data.get("username") or "").strip()
        if len(username) < 3:
            self.errors["username"] = "Must be at least 3 characters."
        else:
            self.cleaned_data["username"] = username

        raw_age = self.data.get("age")
        try:
            age = int(raw_age)
        except (TypeError, ValueError):
            self.errors["age"] = "Enter a whole number."
        else:
            if age < 18:
                self.errors["age"] = "You must be 18 or older."
            else:
                self.cleaned_data["age"] = age

        return not self.errors

for payload in (
    {"username": "ada_l", "age": "31"},
    {"username": "x", "age": "twelve"},
    {"username": "bob", "age": "16"},
):
    form = RegistrationForm(payload)
    if form.is_valid():
        print("OK  ->", form.cleaned_data)
    else:
        print("BAD ->", form.errors)

# EXPECTED: first OK, the other two show their specific errors.

❓ Frequently Asked Questions

Lesson complete — you can capture and validate user input!

You now know how to define a Form subclass with typed fields and widgets, handle it in a view with the GET-vs-POST pattern, gate on is_valid(), read cleaned_data, and add validators and clean_<field> methods that raise ValidationError.

Practice quiz

What is the main advantage of a ModelForm over a plain Form?

  • It is faster to render
  • It generates fields from a model and can .save() to the database
  • It skips validation
  • It requires no template

Answer: It generates fields from a model and can .save() to the database. A ModelForm builds fields from a model and saves a record with one .save() call.

Which method must you call before reading form.cleaned_data?

  • form.clean()
  • form.validate()
  • form.is_valid()
  • form.save()

Answer: form.is_valid(). is_valid() runs validation and populates cleaned_data; reading it earlier raises AttributeError.

What does form.is_valid() populate when it succeeds?

  • form.errors only
  • form.data
  • form.fields
  • form.cleaned_data

Answer: form.cleaned_data. is_valid() fills cleaned_data with the converted Python values.

Where do you declare which model and fields a ModelForm uses?

  • An inner Meta class with model and fields
  • The __init__ method
  • A separate forms.py decorator
  • The model's Meta class

Answer: An inner Meta class with model and fields. A ModelForm uses an inner Meta class specifying model and fields.

How do you add custom validation to a single field?

  • Override save()
  • Define a clean_<fieldname> method
  • Add a validators decorator to the view
  • Override is_valid()

Answer: Define a clean_<fieldname> method. Define clean_<fieldname>, validate, and return the value so it stays in cleaned_data.

What must a clean_<fieldname> method return?

  • True or False
  • The field's value (cleaned or unchanged)
  • An HttpResponse
  • Nothing

Answer: The field's value (cleaned or unchanged). It must return the value so it remains in cleaned_data.

Which method validates across several fields at once?

  • clean_all()
  • validate_together()
  • the form-wide clean() method
  • full_clean_fields()

Answer: the form-wide clean() method. Override the form-wide clean() to validate fields that depend on each other.

What does the csrf_token template tag insert?

  • A submit button
  • A CSS class
  • A visible error list
  • A hidden input with the CSRF token

Answer: A hidden input with the CSRF token. csrf_token adds a hidden input; without it a POST form is rejected with 403.

What is raised to signal a validation failure in a clean method?

  • forms.ValidationError
  • ValueError
  • AssertionError
  • Http404

Answer: forms.ValidationError. Raise forms.ValidationError with a message to flag invalid input.

When should you use a plain Form instead of a ModelForm?

  • When creating or editing a model instance
  • Always
  • For a contact or search form not tied to a table
  • Only for admin pages

Answer: For a contact or search form not tied to a table. A plain Form suits forms that don't map to a database model, like contact or search forms.

Continue this course

Frequently asked questions

What is the difference between a Form and a ModelForm in Django?

A Form is a standalone class where you declare every field by hand and decide yourself what to do with the cleaned data — there's no automatic link to the database. A ModelForm is built from one of your models, so Django generates the fields for you from the model's columns and can save a record straight to the database with a single .save() call. Use a plain Form for things like a contact or search form that doesn't map to a table, and a ModelForm when the form's purpose is to create or edit a model instance.

Why do I have to call form.is_valid() before reading cleaned_data?

Validation is what populates cleaned_data. When you call form.is_valid(), Django runs every field's conversion and validation logic, fills the cleaned_data dictionary with the converted Python values, and collects any errors. If you read form.cleaned_data before calling is_valid(), the attribute won't exist yet and you'll get an AttributeError. Always treat is_valid() as the gatekeeper: only inside the True branch is cleaned_data safe to use.

What does the csrf_token template tag do and why is it required?

Django protects POST forms against Cross-Site Request Forgery attacks by requiring a secret token to be submitted with the form. The csrf_token template tag inserts a hidden input containing that token. If you leave it out of a POST form, Django's CSRF middleware rejects the submission with a 403 Forbidden error. Every form that uses the POST method must include it inside the form tags.

How do I add custom validation to a single field?

Define a method named clean_<fieldname> on your form class — for example clean_username. Inside it, read the field's value from self.cleaned_data, run your check, and raise forms.ValidationError with a helpful message if something is wrong. Crucially, the method must return the value (cleaned or unchanged) so it stays in cleaned_data. For validation that compares several fields at once, override the form-wide clean() method instead.