Free Ebook cover Django Fundamentals: From First App to a Complete Backend

Django Fundamentals: From First App to a Complete Backend

New course

12 pages

Django Forms and Validation for Reliable Data Entry

Capítulo 7

Estimated reading time: 11 minutes

+ Exercise

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 during is_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 App

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 cleaned

Key points:

  • fields is 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 with add_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 sku

Disabling 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 cleaned

Use 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

GoalPatternWhy it helps
Prevent duplicate submissionsPOST then redirect (PRG)Refresh won’t re-POST
Control editable fieldsUse Meta.fields allowlistIgnores unexpected POST keys
Enforce “read-only after create”disabled=True + server-side clean_...Stops forged updates
Reusable validationCustom validatorsConsistent rules across forms/models
Cross-field rulesclean() and add_errorCentralizes business constraints
Edit multiple objects safelyTwo forms + prefixes + transaction.atomic()Avoids partial saves and name collisions

Now answer the exercise about the content:

When updating an existing product, how can you make the SKU visible but effectively read-only while still protecting against forged POST data?

You are right! Congratulations, now go to the next page

You missed! Try again.

Disabling the field prevents normal editing in the UI, but clients can still forge POST data. A server-side clean_sku() guard ensures the original SKU is kept when editing an existing instance.

Next chapter

Authentication and Authorization in Django Backend Applications

Arrow Right Icon
Download the app to earn free Certification and listen to the courses in the background, even with the screen off.