Django Basics #6: Forms and ModelForm

8 min read

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 #

blog/forms.py
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 #

blog/views.py
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.

  1. GET — empty form instance
  2. POST — build a form from request.POST
  3. is_valid() — run validation
  4. success — use cleaned_data + redirect
  5. failure — re-render the same form (error messages are filled in automatically)

Rendering a form in a template #

The shortest way:

blog/templates/blog/contact.html
{% 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.

per-field rendering
<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 #

customizing widgets
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 #

per-field clean
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 username

The clean_<field> method runs after that field’s validation finishes. It must return the validated value.

Validate multiple fields together #

model-level clean
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 cleaned

clean() runs after every per-field validation. Form-level rules like comparing two fields go here.

To attach the error to a specific field:

add_error
def clean(self):
    cleaned = super().clean()
    if cleaned.get("password") != cleaned.get("password_confirm"):
        self.add_error("password_confirm", "Passwords do not match.")
    return cleaned

ModelForm — 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.

blog/forms.py
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 form
  • fields = "__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() #

blog/views.py
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 instance
  • form.save(commit=False) — builds the instance without saving it. Use this when you need to fill in fields not on the form, like author
  • form.save_m2m() — after commit=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 #

blog/models.py
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 #

file upload 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 #

file upload form
<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.

🚫 missing CSRF token
<form method="post">
  {{ form.as_p }}
  <button>Save</button>
</form>
correct 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.

formset
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 #

SituationUse
1:1 with a DB model (creating/editing posts)ModelForm
Unrelated to a model (search, contact, login)Form
Model + extra fieldsAdd 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 #

blog/views.py — closing
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_validcleaned_data → redirect)
  • Template render — {{ form.as_p }} or per-field manually
  • widget, attrs for HTML attributes / CSS classes
  • clean_<field> and clean() 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=post for an edit form
  • File upload — request.FILES + enctype="multipart/form-data"
  • CSRF token is required on every POST
  • The request.POST or None trick

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.

X