Skip to main content

πŸ“ Django Forms System

Every meaningful web app eventually needs to accept input β€” a sign-up, a comment, a checkout. Django's form system turns that messy, security-sensitive job into a few declarative Python classes that render HTML, validate input, and hand you clean, typed data. This lesson gives you a working command of both Form and ModelForm.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain the form lifecycle β€” unbound vs. bound, is_valid(), and cleaned_data
  • Build a plain Form and a model-backed ModelForm, and choose correctly between them
  • Pick appropriate fields and widgets, and customize widget HTML attributes
  • Wire a form into both a function-based view and a class-based view
  • Render a form in a template β€” quickly with {{ form }} and manually for full control

Estimated Time: 35–45 minutes  β€’  Difficulty: Intermediate

Hands-on: Build a working feedback form end to end β€” form class, view, and template.

In This Lesson

Why the Form System Exists

You could read submitted data straight out of request.POST, coerce every value by hand, check each one, and glue error messages back into the HTML yourself. People did exactly that for years, and it was a reliable source of bugs and security holes. Django's form layer exists so you never have to.

A Django form is a single Python class that owns four jobs at once:

  • Render β€” produce the HTML inputs, labels, and help text.
  • Validate β€” check every field against its rules and collect errors.
  • Convert β€” turn raw strings from the browser into proper Python types (a real datetime.date, a Decimal, a bool).
  • Redisplay β€” when validation fails, show the form again with the user's input intact and errors attached to the right fields.
πŸ’‘ Analogy β€” the restaurant order pad. A waiter (the form) presents a menu (the fields), takes an order, and checks it makes sense (validation) before the ticket ever reaches the kitchen (your view logic). Without that check, customers order dishes that don't exist and specify impossible quantities β€” chaos behind the pass. The form is the disciplined intake step that keeps the kitchen sane.

The Form Lifecycle

The single most important idea in this whole lesson is the difference between an unbound and a bound form, and the pipeline that runs between them.

  • An unbound form β€” ContactForm() β€” has no data attached. You render it for a fresh GET request.
  • A bound form β€” ContactForm(request.POST) β€” has submitted data attached. Only bound forms can be validated.

When you call form.is_valid(), Django runs each field's conversion and validation, then the form-wide clean(). On success it populates the cleaned_data dictionary with typed values; on failure it fills form.errors instead.

flowchart TD A[Raw user input] --> B[Bound Form instance] B --> C{form.is_valid?} C -->|Yes| D[cleaned_data ready] C -->|No| E[form.errors populated] D --> F[Your view logic: save / email / redirect] E --> G[Re-render form with errors] G --> A

πŸ“– Key Terms

Unbound form: a form with no submitted data β€” used to show a blank form.

Bound form: a form initialized with data (request.POST) that can be validated.

cleaned_data: the dictionary of validated, type-converted values, available only after is_valid() returns True.

Form vs. ModelForm

Django gives you two starting points, and picking the right one saves you a lot of typing.

forms.Form β€” the general-purpose form

Use it when the input does not map one-to-one to a database table: a contact form that sends an email, a search box, a "filter results" panel. You declare every field yourself.

# forms.py
from django import forms


class ContactForm(forms.Form):
    name = forms.CharField(max_length=100)
    email = forms.EmailField()
    message = forms.CharField(widget=forms.Textarea)
    subscribe = forms.BooleanField(required=False)

forms.ModelForm β€” the model-backed form

Use it when the form's whole purpose is to create or edit rows of one model β€” the everyday CRUD case. A ModelForm reads your model and builds matching fields, widgets, and validation automatically, and it can .save() straight to the database.

# forms.py
from django import forms
from .models import Article


class ArticleForm(forms.ModelForm):
    class Meta:
        model = Article
        fields = ["title", "content", "category", "tags"]
        # Prefer an explicit list over fields = "__all__" so a new
        # model field is never silently exposed on your public form.

⚠️ Avoid fields = "__all__" on public forms

It works, but it means adding a sensitive field to your model later (say is_staff or internal_notes) instantly makes it editable by anyone who can reach the form. List the fields you intend to expose β€” it is the safer default.

βœ… Rule of thumb

Saving a record? Reach for ModelForm. Doing something else with the input (email, search, a multi-step wizard)? Reach for Form. A blog's CommentForm is a ModelForm; its ContactForm is a plain Form.

Fields & Widgets

There is a distinction worth internalizing early: a field owns the data β€” its type, its validation, whether it's required. A widget owns the HTML β€” which input tag renders and what attributes it carries. One field can wear many widgets.

Common fields

Field Default HTML Validates Common parameters
CharField<input type="text">Text, lengthmax_length, min_length
EmailField<input type="email">Email formatmax_length
IntegerField<input type="number">Whole numbersmin_value, max_value
DecimalField<input type="number">Fixed-precision decimalsmax_digits, decimal_places
BooleanField<input type="checkbox">True/Falserequired
ChoiceField<select>Value in a setchoices
DateField<input type="date">Datesinput_formats
FileField<input type="file">Uploadsmax_length
Field versus widget A field owns validation and the Python data type; a widget owns the rendered HTML input. One field can be paired with different widgets. Field data type validation rules required? TextInput Textarea PasswordInput rendered by a
Figure 1 β€” The same CharField can be rendered as a text box, a text area, or a password input by swapping its widget. The field validates; the widget displays.

Customizing widgets

Pass a widget instance with attrs to control the HTML β€” placeholders, sizes, CSS classes, and numeric bounds all live here.

# forms.py
from django import forms


class FeedbackForm(forms.Form):
    rating = forms.IntegerField(
        widget=forms.NumberInput(attrs={"min": 1, "max": 5}),
    )
    comments = forms.CharField(
        widget=forms.Textarea(attrs={"rows": 5, "placeholder": "Tell us more…"}),
    )
    contact_method = forms.ChoiceField(
        choices=[("email", "Email"), ("phone", "Phone")],
        widget=forms.RadioSelect,
    )
    interests = forms.MultipleChoiceField(
        choices=[("tech", "Technology"), ("sports", "Sports"), ("arts", "Arts")],
        widget=forms.CheckboxSelectMultiple,
    )

Using Forms in Views

The view is where the lifecycle plays out. The canonical pattern handles GET (show a blank form) and POST (validate and act) in one function.

Function-based view

# views.py
from django.shortcuts import render, redirect
from .forms import ContactForm


def contact_view(request):
    if request.method == "POST":
        form = ContactForm(request.POST)      # bound form
        if form.is_valid():
            # cleaned_data holds validated, typed values
            send_contact_email(
                name=form.cleaned_data["name"],
                email=form.cleaned_data["email"],
                message=form.cleaned_data["message"],
            )
            return redirect("contact_success")   # Post/Redirect/Get
    else:
        form = ContactForm()                   # unbound form

    return render(request, "contact.html", {"form": form})

πŸ’‘ The Post/Redirect/Get pattern

After a successful POST, always redirect rather than render. It stops the browser from re-submitting the form if the user hits refresh, which would otherwise create duplicate records or send an email twice.

Class-based view

For the common "show form, validate, redirect" shape, FormView (or CreateView/UpdateView for ModelForms) removes the boilerplate. You override form_valid() to do your work.

# views.py
from django.urls import reverse_lazy
from django.views.generic.edit import FormView
from .forms import ContactForm


class ContactView(FormView):
    template_name = "contact.html"
    form_class = ContactForm
    success_url = reverse_lazy("contact_success")

    def form_valid(self, form):
        send_contact_email(
            name=form.cleaned_data["name"],
            email=form.cleaned_data["email"],
            message=form.cleaned_data["message"],
        )
        return super().form_valid(form)   # performs the redirect

Rendering in Templates

Getting a form onto the page ranges from one line to fully hand-built markup.

Quick rendering

The simplest approach wraps the form in your own <form> tag and lets Django emit the fields. Modern Django (4.0+) also ships form.as_div, which is the recommended default because a <div> layout styles more cleanly than <p>.

<!-- contact.html -->
<form method="post">
    {% csrf_token %}
    {{ form.as_div }}
    <button type="submit">Send</button>
</form>

Other shortcuts exist β€” form.as_p, form.as_table, form.as_ul β€” but they only control the wrapper element.

⚠️ Never forget {% csrf_token %}

Any form that submits with method="post" needs it. Leave it out and Django rejects the submission with a 403 β€” that is Django's built-in Cross-Site Request Forgery protection doing its job.

Manual rendering

When you need full control over layout β€” say, to match a CSS framework β€” render each field by hand. You still get labels, IDs, and errors from the form object.

<form method="post">
    {% csrf_token %}

    <div class="form-group">
        {{ form.name.errors }}
        <label for="{{ form.name.id_for_label }}">Your name</label>
        {{ form.name }}
    </div>

    <div class="form-group">
        {{ form.email.errors }}
        <label for="{{ form.email.id_for_label }}">Email</label>
        {{ form.email }}
    </div>

    <div class="form-group">
        {{ form.message.errors }}
        <label for="{{ form.message.id_for_label }}">Message</label>
        {{ form.message }}
    </div>

    <button type="submit">Send message</button>
</form>

Worked Example: Conference Registration

Let's pull the pieces together into something realistic: a registration form driven by a model, with a couple of extra fields that don't live on the model and a cross-field check. (The clean() method here is a preview β€” the next lesson goes deep on validation.)

# models.py
from django.db import models


class Registration(models.Model):
    SHIRT_SIZES = [
        ("S", "Small"), ("M", "Medium"), ("L", "Large"), ("XL", "Extra Large"),
    ]
    MEAL_CHOICES = [
        ("regular", "Regular"), ("vegetarian", "Vegetarian"), ("vegan", "Vegan"),
    ]

    first_name = models.CharField(max_length=100)
    last_name = models.CharField(max_length=100)
    email = models.EmailField()
    company = models.CharField(max_length=200, blank=True)
    shirt_size = models.CharField(max_length=2, choices=SHIRT_SIZES)
    meal_preference = models.CharField(max_length=20, choices=MEAL_CHOICES)
    registered_at = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return f"{self.first_name} {self.last_name}"
# forms.py
from django import forms
from .models import Registration


class RegistrationForm(forms.ModelForm):
    # Extra fields that are not stored on the model
    confirm_email = forms.EmailField(label="Confirm email")
    accept_terms = forms.BooleanField(
        required=True, label="I accept the terms and conditions",
    )

    class Meta:
        model = Registration
        # registered_at is set automatically, so it is excluded
        fields = ["first_name", "last_name", "email", "company",
                  "shirt_size", "meal_preference"]

    def clean(self):
        cleaned = super().clean()
        if cleaned.get("email") != cleaned.get("confirm_email"):
            self.add_error("confirm_email", "Email addresses don't match.")
        return cleaned
# views.py
from django.urls import reverse_lazy
from django.views.generic.edit import CreateView
from .forms import RegistrationForm
from .models import Registration


class RegistrationCreateView(CreateView):
    model = Registration
    form_class = RegistrationForm
    template_name = "registration_form.html"
    success_url = reverse_lazy("registration_success")

What CreateView does for you

On GET it renders a blank form; on a valid POST it calls form.save() to insert the row and then redirects to success_url. The two model-absent fields (confirm_email, accept_terms) are validated but never written to the database.

Hands-on Exercise

πŸ‹οΈ Build a working feedback form

Objective: Wire a form from class to view to template so a submission shows a thank-you page.

Instructions

  1. In forms.py, create a FeedbackForm(forms.Form) with name (CharField), email (EmailField), rating (IntegerField, 1–5 via widget attrs), and comments (CharField with a Textarea widget, not required).
  2. In views.py, write feedback_view using the GET/POST pattern. On a valid POST, print form.cleaned_data to the console and redirect to a feedback_thanks URL.
  3. In feedback.html, render the form with method="post", {% csrf_token %}, {{ form.as_div }}, and a submit button.
  4. Submit once with valid data (confirm the redirect) and once with a rating of 9 (confirm the error re-displays).
πŸ’‘ Hint

Remember the two states: FeedbackForm(request.POST) for the POST branch, plain FeedbackForm() for the GET branch. cleaned_data only exists after is_valid() returns True β€” reading it earlier raises AttributeError.

βœ… Solution
# forms.py
from django import forms


class FeedbackForm(forms.Form):
    name = forms.CharField(max_length=100)
    email = forms.EmailField()
    rating = forms.IntegerField(
        min_value=1, max_value=5,
        widget=forms.NumberInput(attrs={"min": 1, "max": 5}),
    )
    comments = forms.CharField(required=False, widget=forms.Textarea)


# views.py
from django.shortcuts import render, redirect
from .forms import FeedbackForm


def feedback_view(request):
    if request.method == "POST":
        form = FeedbackForm(request.POST)
        if form.is_valid():
            print(form.cleaned_data)   # in real code: save or email
            return redirect("feedback_thanks")
    else:
        form = FeedbackForm()
    return render(request, "feedback.html", {"form": form})
<!-- feedback.html -->
<form method="post">
    {% csrf_token %}
    {{ form.as_div }}
    <button type="submit">Send feedback</button>
</form>

Setting both min_value/max_value on the field and the widget attrs gives you server-side validation (authoritative) plus a browser hint (convenient).

Best Practices

βœ… Do

  • Prefer ModelForm whenever the form maps to a model β€” let Django generate the fields.
  • List fields explicitly in Meta.fields; treat "__all__" as a smell on anything user-facing.
  • Always include {% csrf_token %} and redirect after a successful POST.
  • Read validated values from cleaned_data, never from request.POST directly.

❌ Don't

  • Don't touch form.cleaned_data before calling is_valid().
  • Don't put presentation-only concerns (a placeholder, a CSS class) in the field β€” put them in the widget's attrs.
  • Don't rely on browser validation (required, min) for security β€” it is trivially bypassed. Server-side field rules are the real guardrail.
  • Don't re-render the same POST on refresh; use Post/Redirect/Get.

Summary & Quiz

πŸŽ‰ Key Takeaways

  • A Django form renders, validates, converts, and redisplays input from one class.
  • Forms are unbound (no data) or bound (has data); only bound forms validate, and cleaned_data appears only after is_valid().
  • Use Form for non-model input and ModelForm for CRUD; ModelForm builds fields from the model and can .save().
  • Fields own validation and type; widgets own the HTML.
  • The GET/POST view pattern plus Post/Redirect/Get and {% csrf_token %} is the standard, safe way to handle a form.

🎯 Quick Quiz

Question 1: When is a form's cleaned_data dictionary available?

Question 2: You need a form to create and edit rows of a Product model. Which base class is the natural fit?

Question 3: What is the role of a widget in Django's form system?

πŸ“š Further Reading

πŸš€ What's Next?

You can now build and render forms β€” but the real power is in validation. Next we go deep on field-level, form-level, and model-level validation, custom validators, and clean multi-field rules.

πŸŽ‰ Well done!

You've got the whole form lifecycle in your head. Let's make it bulletproof with validation.