RizTech Academy logo
RizTech Academy
Views, Templates and FormsLesson 4 of 635 min

Forms and ModelForms

Forms are where untrusted input enters your application, and Django's forms framework handles the three hard parts of that safely: rendering the fields, validating the submitted data, and turning valid data into something you can use (or save). ModelForm goes further and builds a form straight from a model, so creating and editing records is almost no code. This lesson covers both, and the validation that is the whole reason forms exist.

Why not just read request.POST?

You could pull raw values out of request.POST yourself. You should not, because you would then have to manually check every field is present, is the right type, is within limits, re-display the form with the user's input and error messages when something is wrong, and guard against malicious input. That is exactly what the forms framework does for you, correctly. A form is a validation and rendering layer over untrusted input — and untrusted input is precisely where security and correctness bugs live.

ModelForm: a form from a model

When a form maps to a model — creating or editing a Patient — ModelForm generates it:

# patients/forms.py
from django import forms
from .models import Patient


class PatientForm(forms.ModelForm):
    class Meta:
        model = Patient
        fields = ["name", "phone", "city"]        # which model fields to include

From this, Django infers each field's type and validation from the model — name becomes a required text input (because the model field is non-blank), phone respects its max_length, and so on. You list fields explicitly (never use fields = "__all__" on anything user-facing — it silently exposes every field, including ones you add later and did not mean to publish). A ModelForm also knows how to save: form.save() creates or updates the model instance.

The view pattern: GET shows, POST validates

The canonical form view, branching on method (verified end to end in Nidaan):

from django.shortcuts import render, redirect
from django.contrib import messages
from .forms import PatientForm


def patient_create(request):
    if request.method == "POST":
        form = PatientForm(request.POST)         # bind the submitted data
        if form.is_valid():                       # run all validation
            patient = form.save()                 # valid: save the model
            messages.success(request, f"Added {patient.name}.")
            return redirect("patient_list")       # then redirect
    else:
        form = PatientForm()                      # GET: an empty, unbound form
    return render(request, "patients/patient_form.html", {"form": form})

Two states of a form matter. An unbound form (PatientForm()) has no data — you show it on GET. A bound form (PatientForm(request.POST)) has submitted data to validate — you create it on POST. The whole flow turns on form.is_valid(): verified, a valid submission returns 302 and creates the record; an invalid one returns 200 and re-renders the form with the user's input and the errors attached, because the bound form remembers what was submitted. You never lose the user's typing on a validation error.

Rendering a form and the CSRF token

In the template, a form renders itself:

<form method="post">
  {% csrf_token %}
  {{ form.as_p }}
  <button>Save</button>
</form>

{{ form.as_p }} renders each field wrapped in a <p> (there is also as_div, the modern default, and as_table); or you can render fields individually ({{ form.name }}, {{ form.name.errors }}) for full control over layout. The non-negotiable line is {% csrf_token %}: Django blocks any POST without a valid CSRF token, which is the built-in protection against cross-site request forgery. Omit it and your form returns a 403 — this is Django protecting you, not a bug. Every POST form needs {% csrf_token %} inside it.

Validation: the point of the whole thing

Forms validate at three levels, and knowing where each lives is key:

  • Field-level, built in. Required, max_length, EmailField format — inferred from the model or the form field. You get these for free.
  • Field-level, custom — clean_<field>. To validate one field with your own rule, add a method named clean_<fieldname>:
    def clean_phone(self):
        phone = self.cleaned_data["phone"]
        digits = phone.lstrip("+").replace(" ", "")
        if not digits.isdigit() or len(digits) < 10:
            raise forms.ValidationError("Enter a valid phone number (at least 10 digits).")
        return phone                              # ALWAYS return the cleaned value

Verified: submitting phone="12" re-renders the form (200) with the error "Enter a valid phone number…" and does not create the patient. A clean_<field> method must return the cleaned value (or raise ValidationError) — forgetting the return silently wipes the field.

  • Form-level — clean(). To validate across multiple fields (e.g. "end date must be after start date"), override clean() and check self.cleaned_data. Use this when the rule involves more than one field.

form.cleaned_data is the dictionary of validated, type-converted values — available only after is_valid() returns True. Reading cleaned_data is how you get safe data out of a form: it has passed every check and been converted to proper Python types (a DateField gives you a date, not a string).

Plain forms.Form for non-model data

Not every form maps to a model — a search box, a contact form, a filter. For those, subclass forms.Form and declare fields directly:

class AppointmentSearchForm(forms.Form):
    city = forms.CharField(required=False)
    status = forms.ChoiceField(choices=Appointment.Status.choices, required=False)

Same validation and rendering machinery, no model behind it. Reach for forms.Form when the data is not a model instance, and ModelForm when it is.

Check your work

Why use a form, not request.POST. The form validates untrusted input, re-renders with errors and the user's data, and converts to proper types — the correctness/security layer you would otherwise hand-roll.

What ModelForm gives. A form built from a model — fields, validation inferred from the model, and form.save(). List fields explicitly; never "__all__" on user-facing forms.

The view pattern. GET → unbound empty form; POST → bound form, is_valid() then save+redirect (verified 302, created) or re-render with errors (verified 200, not created).

The CSRF token. {% csrf_token %} in every POST form; without it Django returns 403 by design.

The three validation levels. Built-in field checks; clean_<field> for one field (must return the value — verified phone="12" rejected); clean() for cross-field rules.

What cleaned_data is. The validated, type-converted values, available after is_valid() is True — the safe data to use or save.

Practice

  1. Build PatientForm as a ModelForm with explicit fields; render it with {{ form.as_p }} and {% csrf_token %}.
  2. Submit a valid patient and confirm a 302 redirect and a created record; submit an invalid one and confirm a 200 re-render with the input preserved.
  3. Remove {% csrf_token %}, submit, and read the 403; add it back.
  4. Add the clean_phone method; submit phone="12" and confirm the error message and that no record is created. Then omit the return and observe the field silently emptying.
  5. Add a clean() method enforcing a cross-field rule and trigger it.
  6. Build a plain forms.Form search form (not a ModelForm) and use its cleaned_data in a view to filter a queryset.

Official documentation

Next: messages, redirects and the POST-redirect-GET pattern.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship