The recording for this lesson has not been published yet.
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.
| Class | Fields come from | What save() does | Use 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"]
A form has one job: turn untrusted strings from an HTTP request into clean Python objects, or into error messages. Everything else - saving, redirecting, sending mail - belongs to the view.
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:
| Level | Template code | Control |
|---|---|---|
| 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 |
Since Django 4.1 {{ form }} renders with
as_div, which produces per-field wrappers you can style. Prefer it over
the old as_p and never use as_table.
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})
is_valid() is not one check. It is a pipeline, and knowing the order tells you
where to put your own rule.
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
| Technique | Where the message shows up | Stops 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 |
If that field failed validation the key does not exist and you get a
KeyError - a 500 page instead of a friendly error. Always use
cleaned.get("x").
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
PostSearchForm with a q field and use it in
post_list to filter posts. Notice that it has no save().{{ form.as_p }} in
post_edit.html with the field loop above.clean_title that rejects titles shorter than 5 characters, and check
that the message appears under the input.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.fields to exclude, add an is_featured
boolean to Post, and see the checkbox appear in the public form. Then
change it back.