π 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(), andcleaned_data - Build a plain
Formand a model-backedModelForm, 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, aDecimal, abool). - 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 freshGETrequest. - 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.
π 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, length | max_length, min_length |
EmailField | <input type="email"> | Email format | max_length |
IntegerField | <input type="number"> | Whole numbers | min_value, max_value |
DecimalField | <input type="number"> | Fixed-precision decimals | max_digits, decimal_places |
BooleanField | <input type="checkbox"> | True/False | required |
ChoiceField | <select> | Value in a set | choices |
DateField | <input type="date"> | Dates | input_formats |
FileField | <input type="file"> | Uploads | max_length |
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
- In
forms.py, create aFeedbackForm(forms.Form)withname(CharField),email(EmailField),rating(IntegerField, 1β5 via widget attrs), andcomments(CharField with a Textarea widget, not required). - In
views.py, writefeedback_viewusing the GET/POST pattern. On a valid POST, printform.cleaned_datato the console and redirect to afeedback_thanksURL. - In
feedback.html, render the form withmethod="post",{% csrf_token %},{{ form.as_div }}, and a submit button. - 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 fromrequest.POSTdirectly.
β Don't
- Don't touch
form.cleaned_databefore callingis_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_dataappears only afteris_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
- Django docs β Working with forms
- Django docs β Creating forms from models (ModelForm)
- Django docs β Form fields reference
π 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.