Forms are Django’s primary tool for turning untrusted input (usually from HTTP requests) into trusted Python data. A form class defines fields, validation rules, and “cleaning” logic that converts raw strings into typed values (dates, integers, model instances). When you use forms consistently, you get reliable data entry, predictable error handling, and safer updates (only intended fields are editable).
Form vs ModelForm: when to use each
- Form: best for non-model inputs (search/filter forms, multi-step wizards, “action” forms), or when you want full control over fields and saving.
- ModelForm: best for create/update flows backed by a model. It automatically generates fields from the model and can save instances.
Both types share the same validation lifecycle: instantiate with data, call is_valid(), then use cleaned_data (and optionally save() for ModelForms).
How validation works: field validation, clean(), and errors
Django validates in layers:
- Field-level: each field validates its type and constraints (e.g.,
EmailField,IntegerField(min_value=...)). - Custom field cleaning: implement
clean_<fieldname>()to validate/transform a single field. - Form-wide cleaning: implement
clean()to validate relationships between fields. - Model-level validation (ModelForm): model field validators,
Model.clean(), and uniqueness checks can be triggered duringis_valid().
Errors are stored in form.errors (a dict-like object). You can attach errors to a specific field or to the form in general (non-field errors).
if form.is_valid(): # safe to use form.cleaned_dataelse: print(form.errors) # field errors print(form.non_field_errors())Example domain: products and suppliers
The examples below assume a backend tool that manages products and their suppliers. You can adapt the patterns to any domain.
Continue in our app.
You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.
Or continue reading below...Download the app
# inventory/models.pyfrom django.db import modelsfrom django.core.validators import MinValueValidatorclass Supplier(models.Model): name = models.CharField(max_length=120, unique=True) email = models.EmailField(blank=True) is_active = models.BooleanField(default=True) def __str__(self): return self.nameclass Product(models.Model): supplier = models.ForeignKey(Supplier, on_delete=models.PROTECT, related_name="products") sku = models.CharField(max_length=32, unique=True) name = models.CharField(max_length=200) price = models.DecimalField(max_digits=10, decimal_places=2, validators=[MinValueValidator(0)]) is_active = models.BooleanField(default=True) created_at = models.DateTimeField(auto_now_add=True) def __str__(self): return f"{self.sku} - {self.name}"ModelForm for create/update with controlled editable fields
A ModelForm lets you explicitly control which fields are editable. This is important for backend reliability: never trust that the browser only submits what you rendered.
# inventory/forms.pyfrom django import formsfrom .models import Productclass ProductForm(forms.ModelForm): class Meta: model = Product # Explicit allowlist of editable fields fields = ["supplier", "sku", "name", "price", "is_active"] # Alternatively, you can exclude fields, but allowlist is safer. # exclude = ["created_at"] def clean_sku(self): sku = self.cleaned_data["sku"].strip().upper() if " " in sku: raise forms.ValidationError("SKU cannot contain spaces.") return sku def clean(self): cleaned = super().clean() name = cleaned.get("name") price = cleaned.get("price") # Cross-field validation example if name and price is not None and "free" in name.lower() and price > 0: self.add_error("price", "If the name contains 'free', price must be 0.") return cleanedKey points:
fieldsis an allowlist. If a malicious client submits extra fields, they are ignored.clean_sku()normalizes input and enforces a rule.clean()handles cross-field rules and can attach errors withadd_error.
Customizing widgets and help text
You can improve data entry by using appropriate widgets and hints.
class ProductForm(forms.ModelForm): class Meta: model = Product fields = ["supplier", "sku", "name", "price", "is_active"] widgets = { "price": forms.NumberInput(attrs={"step": "0.01", "min": "0"}), } help_texts = { "sku": "Uppercase letters/numbers recommended; no spaces.", }Custom validators: reusable rules shared across forms and models
Use validators when a rule should be reused or enforced at the model field level. Validators raise ValidationError.
# inventory/validators.pyimport refrom django.core.exceptions import ValidationErrordef validate_sku_format(value: str): # Example: ABC-1234 pattern if not re.fullmatch(r"[A-Z]{3}-\d{4}", value): raise ValidationError("SKU must match pattern ABC-1234.")Attach it to the model field (enforced everywhere, including admin and ModelForms):
# inventory/models.pyfrom .validators import validate_sku_formatclass Product(models.Model): sku = models.CharField(max_length=32, unique=True, validators=[validate_sku_format])Or attach it to a form field (enforced only for that form):
class ProductForm(forms.ModelForm): sku = forms.CharField(validators=[validate_sku_format]) class Meta: model = Product fields = ["supplier", "sku", "name", "price", "is_active"]Handling form errors in templates (field and non-field)
When validation fails, re-render the same template with the bound form (containing user input and errors). Show errors near fields and show non-field errors at the top.
<form method="post"> {% csrf_token %} {% if form.non_field_errors %} <div class="alert">{{ form.non_field_errors }}</div> {% endif %} <div> {{ form.sku.label_tag }} {{ form.sku }} {{ form.sku.errors }} </div> <div> {{ form.name.label_tag }} {{ form.name }} {{ form.name.errors }} </div> <button type="submit">Save</button></form>If you prefer quick rendering, {{ form.as_p }} works, but explicit rendering gives you more control over layout and error placement.
POST/Redirect/GET (PRG) for create/update to prevent duplicate submissions
For create/update flows, follow this pattern:
- GET: render an empty form (create) or a form bound to an instance (update).
- POST: validate and save; on success, redirect to another URL (detail/list/edit page).
- Redirect: browser lands on a GET page, so refresh won’t resubmit the POST.
Create view (step-by-step)
# inventory/views.pyfrom django.contrib import messagesfrom django.shortcuts import render, redirectfrom .forms import ProductFormdef product_create(request): if request.method == "POST": form = ProductForm(request.POST) if form.is_valid(): product = form.save() messages.success(request, "Product created.") return redirect("inventory:product_edit", pk=product.pk) else: form = ProductForm() return render(request, "inventory/product_form.html", {"form": form})Notes:
- On invalid POST, the same template is rendered with a bound form, preserving input and showing errors.
- On success, redirect to avoid duplicate submissions.
Update view with instance binding
from django.shortcuts import get_object_or_404def product_edit(request, pk): product = get_object_or_404(Product, pk=pk) if request.method == "POST": form = ProductForm(request.POST, instance=product) if form.is_valid(): form.save() messages.success(request, "Changes saved.") return redirect("inventory:product_edit", pk=product.pk) else: form = ProductForm(instance=product) return render(request, "inventory/product_form.html", {"form": form, "product": product})instance=... is what turns a ModelForm into an update form. Without it, you would create a new record.
Preventing unintended edits: disabling fields and server-side enforcement
Sometimes you want a field visible but not editable (e.g., SKU after creation). You can disable it in the form, but you should also enforce the rule server-side.
class ProductForm(forms.ModelForm): class Meta: model = Product fields = ["supplier", "sku", "name", "price", "is_active"] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if self.instance and self.instance.pk: self.fields["sku"].disabled = True def clean_sku(self): sku = self.cleaned_data["sku"].strip().upper() # If editing an existing product, keep original SKU regardless of POST if self.instance and self.instance.pk: return self.instance.sku return skuDisabling a field prevents normal browsers from editing it, but a client can still forge POST data. The clean_sku() guard ensures the server keeps the original value.
Editing related objects: inline-like flows with multiple forms
Backend tools often need to edit a parent object and a related object on the same page (e.g., edit a supplier and also update a “primary product” at the same time). You can do this with two forms and careful validation.
# inventory/forms.pyfrom .models import Supplierclass SupplierForm(forms.ModelForm): class Meta: model = Supplier fields = ["name", "email", "is_active"]# inventory/views.pyfrom django.db import transactiondef supplier_and_product_edit(request, supplier_id, product_id): supplier = get_object_or_404(Supplier, pk=supplier_id) product = get_object_or_404(Product, pk=product_id, supplier=supplier) if request.method == "POST": supplier_form = SupplierForm(request.POST, prefix="s", instance=supplier) product_form = ProductForm(request.POST, prefix="p", instance=product) if supplier_form.is_valid() and product_form.is_valid(): with transaction.atomic(): supplier_form.save() product_form.save() return redirect("inventory:supplier_detail", pk=supplier.pk) else: supplier_form = SupplierForm(prefix="s", instance=supplier) product_form = ProductForm(prefix="p", instance=product) return render( request, "inventory/supplier_product_form.html", {"supplier_form": supplier_form, "product_form": product_form}, )Important details:
- Prefixes prevent field name collisions when two forms have fields with the same name.
- Atomic transaction ensures both saves succeed or both roll back.
Template snippet for two forms
<form method="post"> {% csrf_token %} <fieldset> <legend>Supplier</legend> {{ supplier_form.as_p }} </fieldset> <fieldset> <legend>Product</legend> {{ product_form.as_p }} </fieldset> <button type="submit">Save both</button></form>Search and filter forms for backend tools
Search/filter forms are typically GET forms (so the URL is shareable and bookmarkable). Use a regular forms.Form and apply cleaned values to a queryset.
Define a filter form
# inventory/forms.pyfrom django import formsclass ProductFilterForm(forms.Form): q = forms.CharField(required=False, label="Search") supplier = forms.IntegerField(required=False) is_active = forms.NullBooleanField(required=False, label="Active?") min_price = forms.DecimalField(required=False, min_value=0) max_price = forms.DecimalField(required=False, min_value=0) def clean(self): cleaned = super().clean() min_price = cleaned.get("min_price") max_price = cleaned.get("max_price") if min_price is not None and max_price is not None and min_price > max_price: raise forms.ValidationError("Min price cannot be greater than max price.") return cleanedUse it in a list view (GET-only validation)
# inventory/views.pyfrom django.db.models import Qdef product_list(request): form = ProductFilterForm(request.GET) qs = Product.objects.select_related("supplier").order_by("sku") if form.is_valid(): q = form.cleaned_data.get("q") supplier_id = form.cleaned_data.get("supplier") is_active = form.cleaned_data.get("is_active") min_price = form.cleaned_data.get("min_price") max_price = form.cleaned_data.get("max_price") if q: qs = qs.filter(Q(sku__icontains=q) | Q(name__icontains=q) | Q(supplier__name__icontains=q)) if supplier_id: qs = qs.filter(supplier_id=supplier_id) if is_active is not None: qs = qs.filter(is_active=is_active) if min_price is not None: qs = qs.filter(price__gte=min_price) if max_price is not None: qs = qs.filter(price__lte=max_price) return render(request, "inventory/product_list.html", {"form": form, "products": qs})Even though it’s a GET form, you still validate it. This prevents invalid parameters from causing errors and gives you a consistent place to define rules (like min/max price relationships).
Template snippet for a GET filter form
<form method="get"> {{ form.q }} {{ form.min_price }} {{ form.max_price }} <button type="submit">Filter</button></form>Common reliability patterns and pitfalls
| Goal | Pattern | Why it helps |
|---|---|---|
| Prevent duplicate submissions | POST then redirect (PRG) | Refresh won’t re-POST |
| Control editable fields | Use Meta.fields allowlist | Ignores unexpected POST keys |
| Enforce “read-only after create” | disabled=True + server-side clean_... | Stops forged updates |
| Reusable validation | Custom validators | Consistent rules across forms/models |
| Cross-field rules | clean() and add_error | Centralizes business constraints |
| Edit multiple objects safely | Two forms + prefixes + transaction.atomic() | Avoids partial saves and name collisions |