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,EmailFieldformat — 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 namedclean_<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"), overrideclean()and checkself.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
- Build
PatientFormas aModelFormwith explicitfields; render it with{{ form.as_p }}and{% csrf_token %}. - 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.
- Remove
{% csrf_token %}, submit, and read the 403; add it back. - Add the
clean_phonemethod; submitphone="12"and confirm the error message and that no record is created. Then omit thereturnand observe the field silently emptying. - Add a
clean()method enforcing a cross-field rule and trigger it. - Build a plain
forms.Formsearch form (not aModelForm) and use itscleaned_datain a view to filter a queryset.
Official documentation
- Django — Working with forms — The forms framework end to end.
- Django — ModelForm — Building forms from models and
save(). - Django — Form and field validation —
clean_<field>,clean(), andcleaned_data.
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