Course outline

Django / Reliable application workflows / Lesson 8

ModelForms and domain validation

Use declared writable fields and explicit cleaning while keeping server-owned identity and concurrent constraints outside client control.

About 4 min readContent reviewed
On this page

Lesson goals

  • Choose the operation’s input shape
  • Test a cross-field contract without persistence
  • Validate the final invariant at trusted boundaries

Choose the operation’s input shape

A ModelForm connects declared form fields to a model instance and can reuse appropriate field validation. It does not mean every model attribute should be writable. Specify the fields the operation accepts rather than exposing all fields and hoping the UI hides sensitive ones. Ownership, moderation state, and audit attributes often belong to the server. Create and update forms can have different contracts, especially for partial edits. A model field suitable for storage can still need a narrower form limit or domain-specific validation. Keep the approved input shape reviewable so adding a model field does not silently expand the public write capability.

Clean fields and relationships at the right level

A clean_field method can validate or normalize one field after its basic cleaning. A form clean method can compare multiple cleaned values and raise a non-field error or attach a specific field error. Call the parent clean implementation and remember that a previous field failure can leave a value absent. Do not assume every key exists just because the form is bound. A cross-field rule such as end after start is distinct from validating both values as integers. Keep error codes and messages suitable for correction and localization rather than exposing internal database details.

Test a cross-field contract without persistence

The first program validates a synthetic numeric interval. It uses get on cleaned values and compares them only when both fields cleaned successfully. An equal start and end triggers a non-field error even though each integer is individually valid. The program makes no database connection and does not infer scheduling or timezone semantics from these abstract numbers. Tests can inspect the error location without depending on an exact translated sentence. A real scheduling form would also need explicit date, timezone, overlap, and permission policies; this example isolates one invariant so its result can be derived independently.

python Isolated executable example
from django.conf import settings
settings.configure(
    SECRET_KEY="synthetic-course-key",
    USE_I18N=False, USE_TZ=True,
    INSTALLED_APPS=[],
    DATABASES={"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}},
    DEFAULT_AUTO_FIELD="django.db.models.AutoField",
    ALLOWED_HOSTS=["testserver"],
)
import django
django.setup()

from django import forms

class IntervalForm(forms.Form):
    start = forms.IntegerField(min_value=0)
    end = forms.IntegerField(min_value=0)
    def clean(self):
        data = super().clean()
        start, end = data.get("start"), data.get("end")
        if start is not None and end is not None and end <= start:
            raise forms.ValidationError("End must be after start.")
        return data

good = IntervalForm({"start": "2", "end": "5"})
bad = IntervalForm({"start": "2", "end": "2"})
print(good.is_valid(), good.cleaned_data["end"])
print(bad.is_valid(), bool(bad.non_field_errors()))
Expected output
True 5
False True

Both inputs contain valid integers, but the equal-ended interval violates the shared invariant and produces a non-field error.

Assign server-owned fields after form acceptance

The second fragment assumes a catalog Course model with title, seats, and an owner foreign key to the configured user. Its ModelForm permits only title and seats. The authenticated view binds POST even when the submitted mapping is empty, uses commit=False, assigns the current user, and saves under the application’s defined workflow. It also requires named routing, session/auth middleware, a real template with CSRF handling, and an isolated database schema. The client cannot choose owner through this form. If the form includes many-to-many values, commit=False requires the documented save_m2m step after the instance is saved; that extra step is not needed by this two-field fixture.

python Application-context fragment
from django import forms
from django.contrib.auth.decorators import login_required
from django.shortcuts import redirect, render
from django.views.decorators.http import require_http_methods
from .models import Course

class CourseForm(forms.ModelForm):
    class Meta:
        model = Course
        fields = ["title", "seats"]

@login_required
@require_http_methods(["GET", "POST"])
def create_course(request):
    form = CourseForm(request.POST if request.method == "POST" else None)
    if request.method == "POST" and form.is_valid():
        course = form.save(commit=False)
        course.owner = request.user
        course.save()
        return redirect("course-detail", course_id=course.pk)
    return render(request, "catalog/course_form.html", {"form": form})
Expected output
Valid authorized POST in the declared app: save with current user as owner, then redirect.

Requires the declared Course ownership schema, routes, middleware, template, and normal CSRF protection. The authenticated owner is assigned by the server; this application fragment is not executed.

Validate the final invariant at trusted boundaries

A form can accept input before the current database state changes. Uniqueness, capacity, and ownership-sensitive transitions therefore still need constraints and a transaction strategy appropriate to the operation. Do not treat form validation as a reservation or authorization grant. Test extra submitted fields, empty POST data, invalid cross-field values, valid updates to an existing instance, and excluded ownership fields. For update views, obtain the instance through the authorized scope before binding the form. A hidden owner field or disabled HTML control is not server enforcement. Preserve entered safe values and actionable errors when a write is rejected.

Common errors and fixes

An empty POST shows no validation errors

Why it happens
request.POST or None turned it into an unbound form.
How to fix it
Bind based on the method, not mapping truthiness.
Prevent it next time
Test an explicitly empty POST.

A cross-field clean method crashes after another field fails

Why it happens
It indexes missing cleaned_data unconditionally.
How to fix it
Read optional cleaned values and compare only when present.
Prevent it next time
Include earlier field failures in form tests.

An owner can be forged through model form input

Why it happens
The form exposes a server-owned field.
How to fix it
Exclude it and assign from trusted identity.
Prevent it next time
Test malicious extra owner values.

Check your understanding

1. Why should an update form receive an already scoped instance?

Show worked solution

Binding valid fields to an unrestricted primary-key lookup can still edit another user’s record. Locate the resource within the caller’s authorized scope, enforce the operation, then bind the permitted fields. Input validation and resource permission are separate guarantees.

2. What extra persistence step can be needed after commit=False with many-to-many fields?

Show worked solution

Save the model instance after assigning trusted fields, then call the form’s documented save_m2m method to persist the relationship selections. Keep these operations inside the appropriate transactional boundary when the domain requires them to succeed together, and handle failure without claiming partial work is complete.

Key takeaways

  • Declare writable fields explicitly.
  • Bound empty input still needs validation.
  • Forms do not reserve concurrent database state.

Official references