Part 4, Forms and user input
Why forms
Every web app that accepts input needs three things: parsing, validation, and rendering. Django’s Form and ModelForm bundle all three.
Plain Form
from django import forms
class ContactForm(forms.Form): name = forms.CharField(max_length=100) email = forms.EmailField() message = forms.CharField(widget=forms.Textarea, max_length=2000)
def clean_email(self): email = self.cleaned_data["email"] if email.endswith("@example.com"): raise forms.ValidationError("example.com addresses are not allowed.") return email
def clean(self): cleaned = super().clean() if cleaned.get("name", "") == cleaned.get("email", ""): raise forms.ValidationError("Name and email can't be identical.") return cleanedTwo validation hooks:
clean_<field>runs per field and returns the cleaned value (or raises).cleanruns once across all fields, the place for cross-field validation.
The view pattern
from django.shortcuts import redirect, renderfrom .forms import ContactForm
def contact(request): if request.method == "POST": form = ContactForm(request.POST) if form.is_valid(): # form.cleaned_data is a dict of validated values send_message(**form.cleaned_data) return redirect("contact_thanks") else: form = ContactForm() return render(request, "blog/contact.html", {"form": form})The POST-redirect-GET pattern is important. A successful POST should respond with a redirect, not an HTML page, otherwise refreshing resubmits the form.
ModelForm
When the form maps directly to a model, save the boilerplate:
from django import formsfrom .models import Post
class PostForm(forms.ModelForm): class Meta: model = Post fields = ["title", "slug", "body", "tags"] widgets = { "body": forms.Textarea(attrs={"rows": 20}), }In the view:
def post_create(request): if request.method == "POST": form = PostForm(request.POST) if form.is_valid(): post = form.save(commit=False) post.author = request.user post.save() form.save_m2m() # because commit=False skipped many-to-many return redirect("blog:detail", slug=post.slug) else: form = PostForm() return render(request, "blog/post_form.html", {"form": form})commit=False is a frequent pattern when you need to set fields not in the form (like author) before writing to the database.
Rendering
<form method="post"> {% csrf_token %}
{{ form.as_p }} {# or .as_div (5.0+), .as_table, .as_ul #}
<button type="submit">Submit</button></form>For full control, render fields individually:
<div> {{ form.title.label_tag }} {{ form.title }} {% if form.title.errors %}<p class="error">{{ form.title.errors.0 }}</p>{% endif %}</div>Django 5 ships {{ form.as_div }} as the default, friendly for modern CSS without needing a library.
CSRF
{% csrf_token %} is required on every <form method="post">. Django’s CsrfViewMiddleware rejects POSTs without it with a 403.
For AJAX, include the CSRF token in the X-CSRFToken header. Django exposes the token at document.cookie (csrftoken) by default.
Formsets, many-at-once
When you need to edit a variable number of records in one form submission:
from django.forms import inlineformset_factoryfrom .models import Author, Post
PostInline = inlineformset_factory( Author, Post, fields=["title", "body"], extra=2, # 2 blank forms can_delete=True,)
def author_edit(request, pk): author = Author.objects.get(pk=pk) if request.method == "POST": formset = PostInline(request.POST, instance=author) if formset.is_valid(): formset.save() return redirect("author_detail", pk=pk) else: formset = PostInline(instance=author) return render(request, "blog/author_edit.html", {"formset": formset})Formsets are powerful but their template rendering is verbose. If you find yourself fighting them, consider a JavaScript-driven UI posting JSON (covered in Part 6).
Third-party helpers
- django-crispy-forms + a template pack (Bootstrap, Tailwind), better rendering without hand-writing each field.
- django-widget-tweaks, per-field CSS classes and attributes from the template (
{{ field|add_class:"input-lg" }}).
Gotchas
- File uploads,
<form enctype="multipart/form-data">is required, and in the view you must passrequest.FILESto the form:PostForm(request.POST, request.FILES). - Initial data, pass
initial={"title": "Draft"}for pre-filled fields onGET. - Validation error on
__all__,form.non_field_errors()renders cross-field errors (those fromclean()). - Boolean checkboxes on edit,
required=Falseis needed, or an unchecked box fails validation on an existing instance. - Deleted FK protection, if your form references a
ForeignKey(on_delete=PROTECT), deleting a referenced row silently breaks submissions; catch and surface gracefully.
What’s next
Part 5 adds authentication, so the “who is submitting this form?” question has a first-class answer.