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.
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.
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()))
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.
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})
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.