Video coming soon

The recording for this lesson has not been published yet.

What we cover

  • Fields, widgets and rendering options
  • Meta.fields, exclude and why exclude is risky
  • clean_field and clean for cross-field rules
  • Showing errors nicely in your templates

Lesson notes

1. Two kinds of form, one API

In the tutorial you wrote exactly one form, PostForm, and it was a ModelForm. That was the right choice, but it hid the more general tool underneath. Django has two form classes and they share the same API: is_valid(), cleaned_data, errors.

ClassFields come fromWhat save() doesUse it when
forms.Form You declare every field by hand Nothing - there is no save() Search box, contact form, "confirm delete", anything that is not a row
forms.ModelForm Generated from the model plus Meta.fields Creates or updates a model instance Create/edit pages for Post, Comment, Profile
# blog/forms.py
from django import forms

from .models import Post


class PostSearchForm(forms.Form):
    """Not backed by a model: nothing here is ever saved."""

    q = forms.CharField(max_length=100, required=False, label="Search")
    published_only = forms.BooleanField(required=False, initial=True)


class PostForm(forms.ModelForm):
    class Meta:
        model = Post
        fields = ["title", "text"]

2. Fields, widgets and rendering

Keep the two ideas apart. A field validates and converts a value (EmailField gives you a checked string, DateField gives you a datetime.date). A widget only decides which HTML element is drawn. Changing the widget never changes the validation.

class PostForm(forms.ModelForm):
    class Meta:
        model = Post
        fields = ["title", "text", "published_date"]
        widgets = {
            "title": forms.TextInput(
                attrs={"class": "form-control", "placeholder": "A short, clear title"}
            ),
            "text": forms.Textarea(attrs={"class": "form-control", "rows": 12}),
            "published_date": forms.DateInput(
                attrs={"class": "form-control", "type": "date"}
            ),
        }
        labels = {"text": "Body"}
        help_texts = {"title": "Shown in the post list and in the browser tab."}

Rendering has three levels of control. Start at the top and move down only when you need to:

LevelTemplate codeControl
Whole form {{ form.as_p }}, {{ form.as_div }} None - fine for the admin-ish page you build in five minutes
Loop {% for field in form %} One markup pattern for every field
Field by field {{ form.title }} Total - and total maintenance too

3. Meta.fields, Meta.exclude, and why exclude is risky

fields is an allow-list, exclude is a deny-list. The difference matters the day you add a column to the model.

# Dangerous: every new model field is silently editable by anyone.
class PostForm(forms.ModelForm):
    class Meta:
        model = Post
        exclude = ["author"]


# Safe: adding Post.is_featured later changes nothing here.
class PostForm(forms.ModelForm):
    class Meta:
        model = Post
        fields = ["title", "text"]

Suppose you later add is_featured or is_published to Post. With exclude those fields appear in the form, so a visitor can POST is_featured=on and promote their own post to the front page. This class of bug is called mass assignment, and fields prevents it by construction. Never write fields = "__all__" on a form that untrusted users can submit.

Fields the user must not choose are set in the view instead:

# blog/views.py
def post_new(request):
    form = PostForm(request.POST or None)
    if request.method == "POST" and form.is_valid():
        post = form.save(commit=False)
        post.author = request.user      # never from the form
        post.save()
        form.save_m2m()                 # needed after commit=False if m2m fields exist
        return redirect("blog:post_detail", pk=post.pk)
    return render(request, "blog/post_edit.html", {"form": form})

4. What is_valid() actually runs

is_valid() is not one check. It is a pipeline, and knowing the order tells you where to put your own rule.

is_valid() full_clean() field.to_python + validators per field clean_<field>() one field only clean() cross-field rules ok raise cleaned_data form.errors redisplay the form a failing field is dropped from cleaned_data but the other fields keep being cleaned

5. clean_<field> and clean()

A clean_<field> method sees one value and must return it. Use it for rules about a single field.

from django.core.exceptions import ValidationError


class PostForm(forms.ModelForm):
    class Meta:
        model = Post
        fields = ["title", "text", "published_date"]

    def clean_title(self):
        title = self.cleaned_data["title"].strip()
        if len(title) < 5:
            raise ValidationError("Give the post a title of at least 5 characters.")
        if title.isupper():
            raise ValidationError("Please do not shout in the title.")
        return title    # forgetting this sets the field to None

clean() runs once, after every field, and is the only place where you can compare fields with each other. Two values may be missing there, because a field that failed earlier is simply absent from cleaned_data.

    def clean(self):
        cleaned = super().clean()
        text = cleaned.get("text", "")
        published = cleaned.get("published_date")

        if published and len(text) < 200:
            # attach the error to a field: it renders next to the input
            self.add_error(
                "text", "A published post needs at least 200 characters."
            )

        if published and published > timezone.now():
            # no field name: this becomes a non-field error
            raise ValidationError("You cannot publish a post in the future.")

        return cleaned
TechniqueWhere the message shows upStops later checks?
raise ValidationError(...) in clean_<field> Next to that field Yes, for that field
raise ValidationError(...) in clean() form.non_field_errors Yes - the rest of clean() is skipped
self.add_error("field", ...) Next to that field No - you can report several problems at once

6. Showing errors nicely

Bootstrap and Tabler expect the is-invalid class on the input and the message in a sibling element. One loop covers the whole form:

<form method="post" novalidate>
  {% csrf_token %}

  {% if form.non_field_errors %}
    <div class="alert alert-danger" role="alert">
      {% for error in form.non_field_errors %}<div>{{ error }}</div>{% endfor %}
    </div>
  {% endif %}

  {% for field in form %}
    <div class="mb-3">
      <label class="form-label" for="{{ field.id_for_label }}">{{ field.label }}</label>
      {{ field }}
      {% if field.help_text %}
        <div class="form-hint">{{ field.help_text }}</div>
      {% endif %}
      {% for error in field.errors %}
        <div class="invalid-feedback d-block">{{ error }}</div>
      {% endfor %}
    </div>
  {% endfor %}

  <button type="submit" class="btn btn-primary">Save</button>
</form>

Two details in that snippet are deliberate. novalidate turns off the browser's own validation so you always see your server-side messages during development, and d-block is needed because Bootstrap hides invalid-feedback until the input itself carries is-invalid.

Adding that class from Python keeps the template simple:

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        for name, field in self.fields.items():
            css = field.widget.attrs.get("class", "form-control")
            if self.is_bound and self.errors.get(name):
                css += " is-invalid"
            field.widget.attrs["class"] = css

7. Exercise

  1. Add a plain PostSearchForm with a q field and use it in post_list to filter posts. Notice that it has no save().
  2. Replace {{ form.as_p }} in post_edit.html with the field loop above.
  3. Add clean_title that rejects titles shorter than 5 characters, and check that the message appears under the input.
  4. Add a clean() rule that refuses a published post with fewer than 200 characters, using add_error. Submit a form that breaks both rules at once and confirm you see both messages.
  5. Change fields to exclude, add an is_featured boolean to Post, and see the checkbox appear in the public form. Then change it back.

Further reading

After this lesson you will

  • Choose between a plain Form and a ModelForm
  • Validate input with clean methods