Django Basics #6: Forms and ModelForm
At the end of #4 URL and Views you handled form data by hand with calls like request.POST.get("title", "").strip(). Doing this every time means re-implementing validation, error messages, re-rendering, and CSRF protection from scratch. Django Form handles all of that in one place.
What a Form actually does #
Django Form takes care of five things in one place.
- HTML form rendering — auto-generate
<input>,<select>,<textarea> - Data validation — types, length, regex, custom rules
- Error messages — translatable, shown per field
- Type conversion — string input → Python types (
int,date,datetime) - Preserving input on re-render — when validation fails, the user’s input stays
A Form class once #
from django import forms
class ContactForm(forms.Form):
name = forms.CharField(max_length=50, label="Name")
email = forms.EmailField(label="Email")
subject = forms.CharField(max_length=200, label="Subject")
message = forms.CharField(widget=forms.Textarea, label="Message")
subscribe = forms.BooleanField(required=False, label="Subscribe to newsletter")A single class produces HTML, validation, and errors for all five fields.
Using it in a view — the standard pattern #
from django.shortcuts import redirect, render
from .forms import ContactForm
def contact(request):
if request.method == "POST":
form = ContactForm(request.POST)
if form.is_valid():
# form.cleaned_data is the validated/converted data
name = form.cleaned_data["name"]
email = form.cleaned_data["email"]
# ... handle (send mail, etc.)
return redirect("blog:contact_thanks")
else:
form = ContactForm() # GET — empty form
return render(request, "blog/contact.html", {"form": form})This is the standard flow of Django Form. It always looks like this.
- GET — empty form instance
- POST — build a form from
request.POST is_valid()— run validation- success — use
cleaned_data+ redirect - failure — re-render the same form (error messages are filled in automatically)
Rendering a form in a template #
The shortest way:
{% extends "base.html" %}
{% block content %}
<h1>Contact</h1>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Send</button>
</form>
{% endblock %}{{ form.as_p }} — wraps each field in a <p> with label + input + error all at once. Great for rapid prototypes.
Other options:
{{ form.as_table }}—<table>rows{{ form.as_ul }}—<ul>{{ form.as_div }}—<div>(Django 4.1+)
Per-field rendering for fine control #
In a real site, design comes in, so you draw fields one by one.
<form method="post" class="space-y-4">
{% csrf_token %}
<div>
<label for="{{ form.name.id_for_label }}">{{ form.name.label }}</label>
{{ form.name }}
{% if form.name.errors %}
<ul class="errors">
{% for error in form.name.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
<div>
<label for="{{ form.email.id_for_label }}">{{ form.email.label }}</label>
{{ form.email }}
{% for error in form.email.errors %}
<p class="error">{{ error }}</p>
{% endfor %}
</div>
{# Non-field errors (form-wide) #}
{% if form.non_field_errors %}
<div class="errors">{{ form.non_field_errors }}</div>
{% endif %}
<button type="submit">Send</button>
</form>Outputting just {{ form.name }} renders the widget as-is (<input type="text" name="name" id="id_name" maxlength="50">). Labels and errors go separately.
Changing widgets — switching the input type #
class ContactForm(forms.Form):
name = forms.CharField(
max_length=50,
widget=forms.TextInput(attrs={"class": "form-control", "placeholder": "Name"}),
)
email = forms.EmailField(
widget=forms.EmailInput(attrs={"class": "form-control"}),
)
message = forms.CharField(
widget=forms.Textarea(attrs={"class": "form-control", "rows": 5}),
)
birthday = forms.DateField(
widget=forms.DateInput(attrs={"type": "date"}),
)
color = forms.ChoiceField(
choices=[("red", "Red"), ("blue", "Blue")],
widget=forms.RadioSelect,
)attrs adds HTML attributes (class, placeholder, data-…). Pairs well with CSS frameworks (Bootstrap, Tailwind).
Custom validation — clean_<field> / clean
#
Validate a single field #
class SignupForm(forms.Form):
username = forms.CharField(max_length=30)
password = forms.CharField(widget=forms.PasswordInput, min_length=8)
password_confirm = forms.CharField(widget=forms.PasswordInput)
def clean_username(self):
username = self.cleaned_data["username"]
from django.contrib.auth import get_user_model
if get_user_model().objects.filter(username=username).exists():
raise forms.ValidationError("This username is already taken.")
return usernameThe clean_<field> method runs after that field’s validation finishes. It must return the validated value.
Validate multiple fields together #
def clean(self):
cleaned = super().clean()
pw1 = cleaned.get("password")
pw2 = cleaned.get("password_confirm")
if pw1 and pw2 and pw1 != pw2:
raise forms.ValidationError("Passwords do not match.")
return cleanedclean() runs after every per-field validation. Form-level rules like comparing two fields go here.
To attach the error to a specific field:
def clean(self):
cleaned = super().clean()
if cleaned.get("password") != cleaned.get("password_confirm"):
self.add_error("password_confirm", "Passwords do not match.")
return cleanedModelForm — auto-generate a form from a model #
Duplicating the same field definitions in both the model and a separate form class is wasteful. ModelForm derives the form fields directly from the model.
from django import forms
from .models import Post
class PostForm(forms.ModelForm):
class Meta:
model = Post
fields = ["title", "content", "tags", "is_published"]
widgets = {
"content": forms.Textarea(attrs={"rows": 10}),
}
labels = {
"title": "Title",
"content": "Body",
"tags": "Tags",
"is_published": "Publish",
}Just model and fields and you’re done. Field types, validation, labels, and max_length all come from the model automatically.
fields vs exclude
#
fields = ["title", "content"]— only the listed ones in the formfields = "__all__"— every field (dangerous, not recommended)exclude = ["author", "created_at"]— exclude only what’s listed
An explicit allowlist (fields = [...]) is safer — it prevents new fields added to the model from accidentally appearing in the form.
ModelForm in a view — save()
#
from django.contrib.auth.decorators import login_required
from django.shortcuts import get_object_or_404, redirect, render
from .forms import PostForm
from .models import Post
@login_required
def post_new(request):
if request.method == "POST":
form = PostForm(request.POST)
if form.is_valid():
post = form.save(commit=False) # not saved to DB yet
post.author = request.user # fill in user
post.save() # then save
form.save_m2m() # save M2M field (tags) separately
return redirect("blog:post_detail", post_id=post.id)
else:
form = PostForm()
return render(request, "blog/post_form.html", {"form": form})
@login_required
def post_edit(request, post_id):
post = get_object_or_404(Post, pk=post_id, author=request.user)
if request.method == "POST":
form = PostForm(request.POST, instance=post) # instance= is the key
if form.is_valid():
form.save()
return redirect("blog:post_detail", post_id=post.id)
else:
form = PostForm(instance=post)
return render(request, "blog/post_form.html", {"form": form})Key patterns:
form.save()— saves to the DB and returns the instanceform.save(commit=False)— builds the instance without saving it. Use this when you need to fill in fields not on the form, likeauthorform.save_m2m()— aftercommit=False, save M2M fields separately (M2M needs the instance PK to save)PostForm(request.POST, instance=post)— when editing, bind the existing instance into the form
Memorize this one pattern and you’ve got most CRUD forms.
File upload #
A form that accepts images / attachments needs two more things.
1. FileField / ImageField on the model
#
class Post(models.Model):
title = models.CharField(max_length=200)
content = models.TextField()
cover = models.ImageField(upload_to="covers/%Y/%m/", blank=True, null=True)
attachment = models.FileField(upload_to="attachments/", blank=True, null=True)upload_to — storage path inside MEDIA_ROOT. Placeholders like %Y/%m/ work too (e.g. covers/2026/04/...).
ImageField requires the Pillow package — uv add Pillow.
2. request.FILES in the view
#
@login_required
def post_new(request):
if request.method == "POST":
form = PostForm(request.POST, request.FILES) # add request.FILES
if form.is_valid():
post = form.save(commit=False)
post.author = request.user
post.save()
form.save_m2m()
return redirect("blog:post_detail", post_id=post.id)
else:
form = PostForm()
return render(request, "blog/post_form.html", {"form": form})Pass request.FILES as the second argument: PostForm(request.POST, request.FILES).
3. enctype in the template
#
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}
<button>Upload</button>
</form>Without enctype="multipart/form-data", the file doesn’t arrive — easy to forget.
CSRF — once more #
Every Django POST request requires a CSRF token. Forget it, and you get 403 Forbidden.
<form method="post">
{{ form.as_p }}
<button>Save</button>
</form><form method="post">
{% csrf_token %}
{{ form.as_p }}
<button>Save</button>
</form>JavaScript fetch / AJAX must include the token in the header too — X-CSRFToken: <token>. See Django’s official Using CSRF protection with AJAX.
Formset — N copies of the same form #
When you need to handle N copies of the same form on one page, use a formset. Example: save 5 comments on one post at once.
from django.forms import modelformset_factory
CommentFormSet = modelformset_factory(Comment, fields=["body"], extra=3)
# view
formset = CommentFormSet(request.POST or None, queryset=Comment.objects.none())
if request.method == "POST" and formset.is_valid():
formset.save()We won’t go deep on this in Basics, but remember the name formset when “N forms on one page” comes up.
Form vs ModelForm — when to use which
#
| Situation | Use |
|---|---|
| 1:1 with a DB model (creating/editing posts) | ModelForm |
| Unrelated to a model (search, contact, login) | Form |
| Model + extra fields | Add fields to ModelForm, or a separate Form |
Start with ModelForm. Drop down to a plain Form only when ModelForm isn’t enough — that’s the natural progression.
Common patterns — in one place #
from django.contrib.auth.decorators import login_required
from django.shortcuts import get_object_or_404, redirect, render
from .forms import PostForm
from .models import Post
@login_required
def post_new(request):
form = PostForm(request.POST or None, request.FILES or None)
if request.method == "POST" and form.is_valid():
post = form.save(commit=False)
post.author = request.user
post.save()
form.save_m2m()
return redirect("blog:post_detail", post_id=post.id)
return render(request, "blog/post_form.html", {"form": form})
@login_required
def post_edit(request, post_id):
post = get_object_or_404(Post, pk=post_id, author=request.user)
form = PostForm(request.POST or None, request.FILES or None, instance=post)
if request.method == "POST" and form.is_valid():
form.save()
return redirect("blog:post_detail", post_id=post.id)
return render(request, "blog/post_form.html", {"form": form, "post": post})The request.POST or None trick — data on POST, None on GET (empty form). A common convention for cutting down if/else branches.
Recap #
What this post nailed down:
- What a Form does — render, validate, errors, type conversion, input preservation
- Standard flow — GET (empty form) / POST (
is_valid→cleaned_data→ redirect) - Template render —
{{ form.as_p }}or per-field manually widget,attrsfor HTML attributes / CSS classesclean_<field>andclean()for custom validation- Throw errors with
forms.ValidationError - ModelForm — auto-generate from a model,
Meta.model + fields save(commit=False)+ fill extra fields +save()+save_m2m()instance=postfor an edit form- File upload —
request.FILES+enctype="multipart/form-data" - CSRF token is required on every POST
- The
request.POST or Nonetrick
In the next post (#7 Django Admin and built-in authentication), you’ll handle Admin’s automatic CRUD screens and Django’s built-in authentication (User, login_required). The promise from #1 — “Admin comes for free” — gets cashed in there.