Filter concepts by levelShowing all levels.

Django · Section 9

Forms

Level
intermediate
Read
36 min
Concepts
5

ModelForm, which generates fields from a model and knows how to save() them; widgets and the other presentation-only knobs (label, initial, help_text); custom validation at the field level (clean_<field>()) and form level (clean()); form errors, server-enforced disabled fields, dynamically added fields, and file uploads; and formsets for managing several forms as one unit.

This section

What is true here

  1. ModelForm generates fields from Meta.model/fields and knows how to save() — always use an explicit fields list, never "__all__".
  2. A widget is presentation only, separate from validation; initial only affects an unbound form's first display.
  3. clean_<field>() must return the cleaned value; clean() runs once, after every field, for validation spanning multiple fields.
  4. disabled=True is enforced server-side (a tampered submission is ignored) — unlike the HTML readonly attribute.
  5. A file-carrying form needs request.FILES as a second constructor argument and enctype="multipart/form-data" in the HTML.

What you will be able to do

  • Build a ModelForm and use commit=False to set a field the form itself doesn't expose
  • Customize a field's widget, label, initial value, and help text without touching validation
  • Write both a clean_<field>() and a clean() method, and know which one a given rule belongs in
  • Handle a file upload correctly, including the required enctype
  • Build a modelformset or inlineformset for managing several related objects at once

Model-backed forms and presentation

Generating a form from a model, and the knobs that change how a field renders without touching what it accepts.

ModelForm

coreintermediate

ModelForm generates form fields automatically from a model's fields, declared via a Meta class naming the model and which fields to include. save() creates or updates a real model instance directly — save(commit=False) returns the unsaved instance for further changes first.

Think of it as

A plain Form describes fields you write out by hand; ModelForm reads them off a model instead — Meta.model and Meta.fields are instructions ('build a form from THIS model, using THESE fields'), not a list of fields you re-type. save() is the payoff: because the form already knows which model it's building, it can turn validated cleaned_data directly into a saved (or almost-saved, with commit=False) instance, something a plain Form has no way to do.

python
class ArticleForm(forms.ModelForm):
    class Meta:
        model = Article
        fields = ["title", "body"]

form = ArticleForm(request.POST, instance=article)  # editing an existing Article
if form.is_valid():
    form.save()

What we're doing: Use save(commit=False) to set a field the form itself doesn't expose — the logged-in user who created the object — before the real save.

articles/views.pypython
def article_create(request):
    form = ArticleForm(request.POST)
    if form.is_valid():
        article = form.save(commit=False)
        article.author = request.user   # not on the form — set explicitly
        article.save()
        return redirect(article)
    return render(request, "articles/form.html", {"form": form})
5
commit=False returns a real Article instance, built from cleaned_data, but not yet written to the database — this is exactly the seam needed to add author before it is.
6
article.save() now performs the actual INSERT, with author already set — a second call is required because commit=False deliberately skipped the first one.

Why this works: author should never be a form field a client can submit — it comes from request.user, not user input — so ModelForm has no way to know about it. commit=False is the documented seam for exactly this situation: fields the form itself doesn't and shouldn't expose, but that must be set before the object is actually saved.

Using fields = "__all__" on a ModelForm

Wrong

python
class ArticleForm(forms.ModelForm):
    class Meta:
        model = Article
        fields = "__all__"   # includes is_featured, internal_notes, everything

Better

python
class ArticleForm(forms.ModelForm):
    class Meta:
        model = Article
        fields = ["title", "body", "category"]   # explicit whitelist

What you see: A field added to the model later (e.g. is_featured, or an internal-only flag) automatically becomes editable through this form — including by a user who should never see or set it.

Why: "__all__" ties the form's exposed fields to whatever the model happens to have, including fields added long after the form was written, by someone who may not remember this form exists. An explicit list fails safe: a new model field is simply absent from the form until someone deliberately adds it.

ModelForm generates fields from the model, then saves back to it

Article (model)

title, body, category

ArticleForm

Meta.model + Meta.fields

is_valid()

save()

creates or updates the instance

  • Article (model)
    • title, body, category
  • ArticleForm
    • Meta.model + Meta.fields
    • is_valid()
  • save()
    • creates or updates the instance

ModelForm.Meta options

ModelForm.Meta options
OptionPurpose
modelthe model class to generate fields from
fieldsexplicit whitelist of model fields to include — prefer this over "__all__"
excludeblacklist instead of a whitelist — riskier as the model grows
widgetsoverride the auto-chosen widget for specific fields
labels / help_textsoverride the auto-derived label/help text per field

Together

python
class ArticleForm(forms.ModelForm):
    class Meta:
        model = Article
        fields = ["title", "body", "category"]
        widgets = {"body": forms.Textarea(attrs={"rows": 10})}

Remember: ModelForm generates fields from Meta.model/fields and knows how to save() directly — use commit=False to set a field the form doesn't expose before the real save; always use an explicit fields list, never "__all__".

See also: forms · detail and editing views

Widgets, labels, initial values, and help text

standardbeginner

A widget is the HTML element rendered for a field (Textarea instead of CharField's default text input) — presentation only, separate from validation. label, initial, and help_text all customize the rendered form without touching what values are accepted.

Think of it as

A field is two separable jobs: what counts as a valid value (its type, its validators) and how it's presented on the page (its widget). Swapping CharField's default <input type="text"> for a Textarea changes nothing about what a valid submission looks like — it's still just a string. label/initial/help_text are the same kind of presentation-only knob: none of the three affects validation at all.

python
class ContactForm(forms.Form):
    subject = forms.CharField(
        label="Your subject",
        initial="General inquiry",
        help_text="Keep it under 100 characters.",
    )

What we're doing: Customize a field's presentation on a plain Form — widget, label, initial value, and help text — with no effect on what value is accepted.

contact/forms.pypython
class ContactForm(forms.Form):
    subject = forms.CharField(
        max_length=100,
        label="Subject line",
        initial="General inquiry",
        help_text="Keep it under 100 characters.",
        widget=forms.TextInput(attrs={"placeholder": "What's this about?"}),
    )
3
max_length=100 is the one line here that affects validation — everything else in this field definition is presentation.
4
label, initial, help_text, and the widget's attrs all change how the field renders, none change what counts as a valid submission.

Why this works: Separating validation rules (max_length) from presentation (label, initial, help_text, widget) means a designer or a later refactor can change how a field looks — placeholder text, a different widget — without any risk of silently loosening or tightening what data the field actually accepts.

Expecting initial to pre-fill a value on a bound (submitted) form

Wrong

python
class ContactForm(forms.Form):
    subject = forms.CharField(initial="General inquiry")

# view
form = ContactForm(request.POST)   # bound — initial is ignored entirely

Better

python
class ContactForm(forms.Form):
    subject = forms.CharField()

# view — for editing existing data, pass it explicitly
form = ContactForm(initial={"subject": existing_value})

What you see: A field that should show a default value on first load appears correctly on GET, but a developer is confused why the same "initial" value never appears anywhere once the form is bound to POST data.

Why: initial only ever affects an UNBOUND form's rendering — the very first, blank display of the form. Once a form is bound (constructed with data, as request.POST is), the field's value comes from that data, and initial is never consulted again — this is intentional, since a bound form is meant to reflect what was actually submitted.

Common widget overrides

Common widget overrides
FieldDefault widgetCommon override
CharFieldTextInputTextarea (for a multi-line body)
CharField (password)TextInputPasswordInput (renders type="password")
ChoiceFieldSelectRadioSelect (renders as radio buttons)
BooleanFieldCheckboxInputrarely overridden
DateFieldDateInputa custom widget with type="date" attrs

Together

python
class ArticleForm(forms.Form):
    body = forms.CharField(widget=forms.Textarea(attrs={"rows": 10}))
    password = forms.CharField(widget=forms.PasswordInput)

Remember: A widget is presentation only, separate from validation; initial only affects an unbound form's first display and is ignored once the form is bound to submitted data.

See also: forms · modelform

Advertisement

Validation and edge cases

Field-level and cross-field custom validation, reading errors back out, and the two trickier cases — disabled fields and file uploads.

Custom validation: validators, clean_<field>(), clean()

coreintermediate

A validator is a reusable function raising ValidationError for one field; clean_<field>() is a method for one field's own form-specific cleaning, run after the field's own validation, and must return the (possibly transformed) value; clean() runs last, once, for validation spanning multiple fields.

Think of it as

Three different scopes, in a fixed order: a validator is reusable across many forms/fields (like EmailValidator); clean_<field>() is this form's own extra rule for just that one field, layered on top; clean() is the only place that can compare two fields against each other, because it's the only stage where every field's cleaned_data already exists. Reach for the narrowest scope that solves the problem — clean() for a rule about ONE field is over-broad.

python
def clean_recipients(self):
    data = self.cleaned_data["recipients"]
    if "fred@example.com" not in data:
        raise ValidationError("You forgot Fred!")
    return data   # required — even if unchanged

What we're doing: Validate that two fields are consistent with each other — something only clean() can do, since it needs both fields' cleaned values at once.

contact/forms.pypython
class ContactForm(forms.Form):
    subject = forms.CharField(max_length=100)
    cc_myself = forms.BooleanField(required=False)

    def clean(self):
        cleaned_data = super().clean()
        subject = cleaned_data.get("subject")
        cc_myself = cleaned_data.get("cc_myself")
        if cc_myself and subject and "help" not in subject:
            self.add_error("subject", "Must mention 'help' when CC'ing yourself.")
        return cleaned_data
3
super().clean() first runs the base implementation — always call it, since it is what actually returns the accumulated cleaned_data dict.
8
cleaned_data.get(...), not cleaned_data[...] — a field that already failed its OWN validation is absent from cleaned_data, and .get() avoids a KeyError on top of that first failure.

Why this works: This rule genuinely needs both subject and cc_myself at once — neither field's own clean_<fieldname>() could express "these two fields together are invalid," because each only ever sees its own value. clean() is the only stage where the whole cleaned_data dict already exists to compare across fields.

Forgetting to return the value from clean_<fieldname>()

Wrong

python
def clean_subject(self):
    data = self.cleaned_data["subject"]
    if len(data) < 5:
        raise ValidationError("Too short.")
    # no return — implicitly returns None

Better

python
def clean_subject(self):
    data = self.cleaned_data["subject"]
    if len(data) < 5:
        raise ValidationError("Too short.")
    return data   # always return, even when unchanged

What you see: form.cleaned_data["subject"] is None after a successful validation, even though the user submitted real text — every other field is fine, only this one silently becomes None.

Why: Whatever clean_<fieldname>() returns REPLACES the field's entry in cleaned_data — Django does not fall back to the original value if nothing is returned. Forgetting the return statement means the function's implicit None return silently overwrites a perfectly valid submitted value.

Three validation scopes, narrowest to broadest

a validator

reusable, one field, across forms

clean_<field>()

this form, one field — must return the value

clean()

once, every field together — must return cleaned_data

  • a validator — reusable, one field, across forms
    • leads to clean_<field>()
  • clean_<field>() — this form, one field — must return the value
    • leads to clean()
  • clean() — once, every field together — must return cleaned_data

Where a validation rule belongs

Where a validation rule belongs
ScopeMechanismWhen it runs
Reusable across formsa validator functionas part of Field.clean()
One field, this form onlyclean_<fieldname>()after that field's own validation
Spans multiple fieldsclean()once, after every field is cleaned

Together

python
def validate_no_profanity(value):
    if "badword" in value.lower():
        raise ValidationError("That word isn't allowed.")

class CommentForm(forms.Form):
    text = forms.CharField(validators=[validate_no_profanity])

Remember: clean_<field>() must return the (possibly transformed) value — Django uses whatever it returns; clean() runs once for cross-field rules and must return cleaned_data — use add_error() to attach a message to a specific field from inside it.

See also: forms · errors and dynamic fields

Errors, disabled/dynamic fields, and file uploads

standardintermediate

form.errors is a dict keyed by field name; form.non_field_errors() returns only clean()-level errors with no specific field. disabled=True on a field makes Django ignore any submitted value for it, unlike the HTML readonly attribute. Fields can be added dynamically in __init__(). File uploads need request.FILES alongside request.POST, and enctype="multipart/form-data" in the HTML.

Think of it as

disabled=True is a lock Django itself enforces server-side — the field is rendered read-only, but more importantly, Django refuses to let a submitted value for it override what was already there, even if someone tampers with the HTML and submits one anyway. The HTML readonly attribute, by contrast, is only a suggestion to the browser; nothing stops a modified request from sending a different value, which the server would then accept. File uploads are a genuinely separate data channel — request.FILES, never request.POST — because that's how multipart form data is actually structured on the wire.

html
{% if form.is_multipart %}
<form method="post" enctype="multipart/form-data">
{% else %}
<form method="post">
{% endif %}
  {% csrf_token %}
  {{ form }}
</form>

What we're doing: Bind both regular POST data and an uploaded file to the same form, and read the file back out of cleaned_data.

profiles/views.pypython
def upload_avatar(request):
    if request.method == "POST":
        form = AvatarForm(request.POST, request.FILES)
        if form.is_valid():
            avatar = form.cleaned_data["avatar"]
            request.user.profile.avatar = avatar
            request.user.profile.save()
            return redirect("profile")
    else:
        form = AvatarForm()
    return render(request, "profiles/upload.html", {"form": form})
3
request.FILES is passed as a second, separate constructor argument — request.POST alone never contains uploaded file data, regardless of the form's enctype.

Why this works: A form with an ImageField/FileField needs both arguments because the browser sends file data in a physically different part of the multipart request body than ordinary fields — Django mirrors that split by keeping request.POST and request.FILES as two separate dict-like objects, and a form binding to file data has to be given both explicitly.

Forgetting enctype="multipart/form-data" on a form with a file field

Wrong

html
<form method="post">
  {% csrf_token %}
  {{ form }}
</form>

Better

html
<form method="post" enctype="multipart/form-data">
  {% csrf_token %}
  {{ form }}
</form>

What you see: request.FILES is always empty in the view, even though the user clearly selected a file in the browser and the form otherwise submits normally.

Why: Without enctype="multipart/form-data", the browser encodes the form body as ordinary URL-encoded text, which has no mechanism for embedding binary file content at all — the file is silently never sent. form.is_multipart() (checkable from a template as {% if form.is_multipart %}) exists specifically so a template can set this attribute correctly without the author needing to remember for every file-carrying form.

disabled vs. HTML readonly

disabled vs. HTML readonly
Aspectdisabled=TrueHTML readonly attr
Enforced byDjango, server-sidethe browser only
Tampered submissionignored — original value keptaccepted as submitted
Appears in cleaned_datanoyes

Together

python
class ProfileForm(forms.ModelForm):
    username = forms.CharField(disabled=True)   # shown, but never changeable via this form

    class Meta:
        model = Profile
        fields = ["username", "bio"]

Remember: form.errors is keyed by field name; non_field_errors() surfaces only clean()-level errors. disabled=True is enforced server-side (safer than readonly); file uploads need both request.FILES and enctype="multipart/form-data".

See also: custom validation · request data

Advertisement

Managing many forms at once

Formsets, for validating and saving a variable-length list of the same form as one unit.

Formsets and model formsets

standardadvanced

A formset is a collection of the same Form rendered and validated together — formset_factory(FormClass) for plain forms, modelformset_factory(Model) for model-backed ones. The management form (hidden fields tracking TOTAL_FORMS etc.) is required in the HTML for the formset to work at all.

Think of it as

A formset is a form that manages a variable-length LIST of another form — think "add another item" rows on an order form, all validated together as one unit. The management form is the accounting behind the scenes: it tells Django how many forms were rendered and how many existed initially, so it can tell a genuinely new row from an edited existing one, and reject a request claiming to submit 500 rows when only 5 were ever rendered.

html
<form method="post">
  {% csrf_token %}
  {{ formset.management_form }}
  {% for form in formset %}{{ form }}{% endfor %}
  <button type="submit">Save all</button>
</form>

What we're doing: Manage a set of Book instances belonging to one Author, using inlineformset_factory so every form is automatically scoped to the parent.

books/views.pypython
def manage_books(request, author_id):
    author = get_object_or_404(Author, pk=author_id)
    BookFormSet = inlineformset_factory(Author, Book, fields=["title"], extra=1)

    if request.method == "POST":
        formset = BookFormSet(request.POST, instance=author)
        if formset.is_valid():
            formset.save()
            return redirect(author.get_absolute_url())
    else:
        formset = BookFormSet(instance=author)
    return render(request, "books/manage.html", {"formset": formset, "author": author})
3
inlineformset_factory(Author, Book, ...) already knows the foreign key relationship between the two models — every form in the set is automatically scoped to author, without repeating that filter by hand.
8
formset.save() creates, updates, AND deletes Book instances in one call — deletion happens for any form the user checked "delete" on, if can_delete was enabled on the factory.

Why this works: inlineformset_factory is the right tool specifically because every Book in this formset belongs to the same author — building the same relationship by hand with modelformset_factory would mean manually filtering the queryset and setting the foreign key on every new instance, exactly the bookkeeping inlineformset_factory automates.

Omitting {{ formset.management_form }} from the template

Wrong

html
<form method="post">
  {% csrf_token %}
  {% for form in formset %}{{ form }}{% endfor %}
  <button type="submit">Save</button>
</form>

Better

html
<form method="post">
  {% csrf_token %}
  {{ formset.management_form }}
  {% for form in formset %}{{ form }}{% endfor %}
  <button type="submit">Save</button>
</form>

What you see: django.core.exceptions.ValidationError: ManagementForm data is missing or has been tampered with — raised on the very next submission, before any individual form's data is even looked at.

Why: The management form's hidden fields (TOTAL_FORMS, INITIAL_FORMS, etc.) are how a formset tells the next request how many forms to expect and reconcile against — without them in the rendered HTML, the submitted POST data has nothing for Django to validate the formset's shape against at all, and it refuses to proceed rather than guess.

The three formset factories

The three formset factories
FactoryForsave() does
formset_factory(Form)plain, non-model formsnothing — plain forms have no save()
modelformset_factory(Model)many instances of one modelcreates/updates instances
inlineformset_factory(Parent, Child)a parent's related objectscreates/updates, scoped to the parent

Together

python
from django.forms import modelformset_factory

AuthorFormSet = modelformset_factory(Author, fields=["name", "title"])
formset = AuthorFormSet(queryset=Author.objects.filter(name__startswith="O"))

Remember: formset_factory is for plain forms, modelformset_factory/inlineformset_factory for model-backed ones — {{ formset.management_form }} must always be rendered, or the whole formset fails on submission.

See also: modelform · forms

Advertisement