Django Forms: Form, ModelForm, Validation & Widgets

Blog / Django · January 14, 2023 · Updated June 10, 2026 · 9 min read
Django Forms: Form, ModelForm, Validation & Widgets

Django forms are Python classes that do three jobs for you: they render HTML inputs, validate and clean incoming request data, and convert that data into native Python types (int, bool, datetime.date, an uploaded file object, and so on) you can trust before it ever reaches your database. Instead of hand-parsing request.POST, you declare the shape of your data once and let Django handle escaping, type coercion, and error messages.

There are two classes you will use constantly:

  • forms.Form - a standalone form whose fields you declare by hand. Use it when the input does not map cleanly onto a single model (search boxes, contact forms, multi-step wizards, filters).
  • forms.ModelForm - a form generated from a model, so you do not repeat field definitions. Use it for the common create/update case where the form mirrors a model.

This guide covers both with current Django 5.x and Python 3.11+ examples. At MicroPyramid we have shipped 50+ Django projects over 12+ years, and clean form handling is one of the highest-leverage things you can get right early.

Defining a forms.Form

A form is a class of Field objects. Each field knows its Python type, its default HTML widget, and how to validate itself. Here are the field types you will reach for most often:

Field Returns (Python type) Default widget Typical use
CharField str TextInput names, short text
EmailField str EmailInput validated email addresses
IntegerField int NumberInput whole numbers
DecimalField Decimal NumberInput money, precise numbers
BooleanField bool CheckboxInput opt-ins, flags
ChoiceField str Select dropdown of fixed options
DateField datetime.date DateInput dates
DateTimeField datetime.datetime DateTimeInput timestamps
FileField UploadedFile ClearableFileInput file uploads
ImageField UploadedFile ClearableFileInput image uploads (needs Pillow)
URLField str URLInput links
ModelChoiceField model instance Select pick one related row

Every field accepts the same core arguments: required (defaults to True), label, help_text, initial, widget, validators, and error_messages. A simple form looks like this:

from django import forms


class MenuForm(forms.Form):
    title = forms.CharField(max_length=100, label="Menu title")
    content = forms.CharField(
        widget=forms.Textarea,
        help_text="Shown under the menu heading.",
    )
    order = forms.IntegerField(min_value=1, initial=1)
    is_active = forms.BooleanField(required=False, initial=True)

The form lifecycle in a view

A form is either unbound (no data, just for display on a GET) or bound (constructed with submitted data, ready to validate). The cycle is always the same:

  1. On GET, create an unbound form and render it.
  2. On POST, bind the data with form = MenuForm(request.POST).
  3. Call form.is_valid() - this runs all validation and populates form.cleaned_data.
  4. If valid, read the cleaned, type-correct values from form.cleaned_data and act on them.
  5. If invalid, re-render the same form - it now carries form.errors and the user's input so nothing is lost.

Here is a complete function-based view:

from django.shortcuts import render, redirect
from .forms import MenuForm
from .models import Menu


def add_menu(request):
    if request.method == "POST":
        form = MenuForm(request.POST)  # bound form
        if form.is_valid():
            data = form.cleaned_data  # cleaned, typed values
            Menu.objects.create(
                title=data["title"],
                content=data["content"],
                order=data["order"],
            )
            return redirect("menu_list")
    else:
        form = MenuForm()  # unbound form for GET

    # On an invalid POST the same bound form (with errors) is re-rendered.
    return render(request, "menu/add_menu.html", {"form": form})

Prefer class-based views for standard create/update flows: FormView handles a plain Form, while CreateView and UpdateView wire a ModelForm to the database for you, so you write far less boilerplate than the view above.

ModelForm: forms generated from a model

A ModelForm builds fields automatically from a model, so you avoid duplicating field definitions and get model-level validation for free. Configure it with an inner Meta:

  • model - the model to bind to.
  • fields - an explicit list of fields to include. Be explicit. Using fields = '__all__' is risky: add a sensitive column to the model later (say is_admin) and it silently becomes editable through the form - a mass-assignment hole.
  • exclude - the inverse of fields. Note a single-element tuple needs a trailing comma: exclude = ('order',), not ('order') (which is just a string).

form.save() writes to the database and returns the instance. Pass commit=False to get an unsaved instance you can tweak before saving - the standard pattern for setting fields the form does not collect (an owner, a slug, a computed order):

from django import forms
from .models import Menu


class MenuForm(forms.ModelForm):
    class Meta:
        model = Menu
        fields = ["title", "content"]  # explicit, not "__all__"
        widgets = {
            "content": forms.Textarea(attrs={"rows": 4}),
        }

    def clean_title(self):
        title = self.cleaned_data["title"]
        qs = Menu.objects.filter(title__iexact=title)
        if self.instance.pk:  # editing: ignore the current row
            qs = qs.exclude(pk=self.instance.pk)
        if qs.exists():
            raise forms.ValidationError("A menu with this title already exists.")
        return title

    def save(self, commit=True):
        instance = super().save(commit=False)
        if not instance.order:
            instance.order = Menu.objects.count() + 1
        if commit:
            instance.save()
        return instance

The view shrinks because the form owns persistence. The request.POST or None idiom builds a bound form on POST and an unbound one on GET in a single line:

from django.shortcuts import render, redirect
from .forms import MenuForm


def add_menu(request):
    form = MenuForm(request.POST or None)
    if request.method == "POST" and form.is_valid():
        menu = form.save()
        return redirect("menu_detail", pk=menu.pk)
    return render(request, "menu/add_menu.html", {"form": form})

Validation: clean methods, validators, and ValidationError

Validation runs in layers during is_valid(), and the server is always the source of truth - never rely on browser/client-side checks alone. You have three hooks:

  • Built-in field rules + validators - max_length, min_value, EmailField format, plus reusable callables you attach via validators=[...].
  • clean_<field>() - validate or normalise one field. It must return the cleaned value.
  • clean() - form-level validation across multiple fields (for example, "field B is required when field A is set"). Use self.add_error() to attach the error to a specific field.

Raise ValidationError to reject a value:

from django import forms
from django.core.exceptions import ValidationError


def validate_not_reserved(value):
    if value.strip().lower() in {"draft", "admin"}:
        raise ValidationError("%(value)s is a reserved title.", params={"value": value})


class MenuForm(forms.Form):
    title = forms.CharField(max_length=100, validators=[validate_not_reserved])
    content = forms.CharField(widget=forms.Textarea)
    publish = forms.BooleanField(required=False)
    publish_at = forms.DateTimeField(required=False)

    def clean_content(self):
        content = self.cleaned_data["content"]
        if len(content) < 10:
            raise forms.ValidationError("Content must be at least 10 characters.")
        return content  # always return the cleaned value

    def clean(self):
        cleaned = super().clean()
        if cleaned.get("publish") and not cleaned.get("publish_at"):
            self.add_error("publish_at", "Set a publish date when 'publish' is on.")
        return cleaned

Widgets and HTML attributes

A widget controls how a field renders in HTML. To add CSS classes, placeholders, or other attributes, pass attrs to the widget - on a Form directly, or via the widgets dict in a ModelForm.Meta:

class MenuForm(forms.Form):
    title = forms.CharField(
        max_length=100,
        widget=forms.TextInput(attrs={
            "class": "form-control",
            "placeholder": "Starters, Mains, Desserts...",
        }),
    )
    content = forms.CharField(
        widget=forms.Textarea(attrs={"class": "form-control", "rows": 4}),
    )

Since Django 5.0 the default form template is the <div>-based renderer, so rendering {{ form }} wraps each field in a <div>. You can also pick an explicit layout helper:

  • {{ form.as_div }} - the modern default, accessible <div> blocks.
  • {{ form.as_p }} - each field in a <p>.
  • {{ form.as_table }} - rows for an outer <table>.
  • {{ form.as_ul }} - list items for an outer <ul>.

Always include the CSRF token

Every form that sends a POST must include {% csrf_token %} inside the <form> tag. Without it Django returns a 403 Forbidden "CSRF verification failed" error. This is Django's built-in cross-site request forgery protection - it is on by default and you should keep it on.

For full control over markup, loop the fields yourself instead of using as_div:

<form method="post" action="{% url 'add_menu' %}">
  {% csrf_token %}

  {% for field in form %}
    <div class="field">
      {{ field.label_tag }}
      {{ field }}
      {% if field.help_text %}<small>{{ field.help_text }}</small>{% endif %}
      {{ field.errors }}
    </div>
  {% endfor %}

  <button type="submit">Save menu</button>
</form>

Handling file uploads

File inputs need two things the rest of this guide did not: the <form> must use enctype="multipart/form-data", and the view must pass request.FILES as the second positional argument to the form. Uploaded files arrive in cleaned_data as file objects, not strings.

from django import forms
from django.shortcuts import render, redirect


class DocumentForm(forms.Form):
    title = forms.CharField(max_length=100)
    attachment = forms.FileField()


def upload(request):
    # request.FILES is required for FileField / ImageField to work.
    form = DocumentForm(request.POST or None, request.FILES or None)
    if request.method == "POST" and form.is_valid():
        uploaded = form.cleaned_data["attachment"]
        for chunk in uploaded.chunks():  # stream large files safely
            ...  # write chunk to your storage backend
        return redirect("upload_done")
    return render(request, "upload.html", {"form": form})
<form method="post" enctype="multipart/form-data">
  {% csrf_token %}
  {{ form.as_div }}
  <button type="submit">Upload</button>
</form>

FormSets and ModelFormSets

When you need to edit many forms at once - a batch of menu rows, line items on an invoice - use a formset. formset_factory builds a set of plain forms; modelformset_factory builds one backed by a queryset. The one template gotcha: always render {{ formset.management_form }}, which carries the hidden bookkeeping fields Django needs to process the set.

from django.forms import modelformset_factory
from django.shortcuts import render, redirect
from .models import Menu

MenuFormSet = modelformset_factory(Menu, fields=["title", "order"], extra=2)


def edit_menus(request):
    formset = MenuFormSet(request.POST or None, queryset=Menu.objects.all())
    if request.method == "POST" and formset.is_valid():
        formset.save()  # creates, updates and deletes in one call
        return redirect("menu_list")
    return render(request, "menu/edit_menus.html", {"formset": formset})

Styling and a final word on security

Django outputs plain, unstyled HTML by design. For production UIs you typically reach for one of:

  • django-crispy-forms - render whole forms with a CSS framework (Bootstrap, Tailwind) layout pack.
  • django-widget-tweaks - add classes/attributes to individual fields from the template ({% render_field form.title class="input" %}).
  • Tailwind utility classes via the attrs approach shown above for full control.

Whatever you choose for the front end, remember the golden rule: client-side validation is a convenience, server-side validation is the law. A motivated user can bypass any HTML5 required attribute, so is_valid() and your clean methods are what actually protect your data. If you want a second pair of eyes on form-heavy, security-sensitive flows, our Django development team and Python engineers do this every day.

Frequently Asked Questions

Form vs ModelForm: when should I use which?

Use a plain forms.Form when the input does not map onto one model - search bars, contact forms, filters, or multi-model wizards. Use a forms.ModelForm for the standard create/update case where the form mirrors a model, because it generates the fields for you, validates against model constraints, and gives you form.save(). A good rule: if you would otherwise copy field definitions out of models.py, switch to a ModelForm.

Why does is_valid() return False / why does my form keep failing validation?

is_valid() is False whenever any field or clean method raised a ValidationError. Inspect form.errors (or form.errors.as_json()) to see exactly which field failed and why. The usual culprits: a required field was left blank, the submitted value could not be coerced to the field type (a non-number in an IntegerField), an HTML name attribute does not match the field name, or a clean_<field>() method forgot to return the value (returning None wipes it). For file fields, forgetting to pass request.FILES also makes them look empty.

How do I add a CSS class or placeholder to a field?

Set them in the field's widget attrs: forms.CharField(widget=forms.TextInput(attrs={"class": "form-control", "placeholder": "Your name"})). In a ModelForm, do the same through the widgets dict in Meta. If you would rather keep styling in the template, the django-widget-tweaks package lets you add classes per field with {% render_field %}.

How do I add custom validation?

For one field, write a clean_<fieldname>() method, do your check, and return the cleaned value (raise forms.ValidationError to reject it). For rules that span several fields, override clean() and use self.add_error("field", "message"). For logic you reuse across forms, write a small validator function and attach it with validators=[my_validator] on the field.

Why is my POST giving a CSRF (403 Forbidden) error?

Django's CSRF protection rejected the request because the token was missing or stale. Add {% csrf_token %} immediately inside every <form method="post">. Make sure django.middleware.csrf.CsrfViewMiddleware is enabled (it is by default) and that you are not rendering the form from a cached fragment. For fetch/AJAX posts, send the X-CSRFToken header with the token value.

How do I handle file uploads in a Django form?

Use a FileField (or ImageField, which needs Pillow) on the form, set enctype="multipart/form-data" on the <form> tag, and construct the form with both data dicts in the view: MyForm(request.POST, request.FILES). After is_valid(), the file is available as a file object in form.cleaned_data["attachment"]; read it with .chunks() so large uploads do not load entirely into memory.

Share this article