Unit 4: Forms in Django

INT253 — Web Development In Python Using Django 10 min read

I. Orientation: Forms as the Boundary Between Browser and Server

A form is the HTML mechanism by which a browser collects user input and submits it to a server; Django wraps that mechanism in a Python class layer (the django.forms package, present since Django 0.96 as "newforms") so that rendering, validation and cleaning happen in one place. Everything in this unit rests on a single idea: untrusted data arrives from the network, and the form is the gate that converts it into trusted Python objects.

Defining properties and conventions assumed throughout:

  • Three responsibilities of a Django form: render HTML widgets, validate submitted data, and convert strings into Python types ("2024-01-05"datetime.date(2024, 1, 5)).
  • All incoming data is strings: HTTP transmits text, so request.POST['age'] is '25', never 25. Type coercion is the form's job.
  • Bound vs unbound: MyForm() is unbound (no data, no errors); MyForm(request.POST) is bound and can be validated. form.is_bound reports which.
  • is_valid() is the trigger: it runs the full cleaning pipeline and populates form.cleaned_data (valid values) and form.errors (an ErrorDict).
  • Never trust client-side validation: HTML5 required or type="email" is a convenience only; an attacker can bypass it with curl.
  • Template convention: every form that submits to Django with method POST must contain {% csrf_token %}.

II. Introduction to Forms — the HTML substrate and Django's abstraction

A. Anatomy of an HTML form

A form is defined by three attributes and a set of named controls.

  • action: target URL. Empty (action="") means submit to the current URL, common in Django views that handle both GET and POST.
  • method: get or post, controlling where data is placed in the request.
  • name attribute: the dictionary key on the server. <input name="email"> becomes request.POST['email'].
  • Widget types: text, password, checkbox, radio, select, hidden, file. A file upload additionally requires enctype="multipart/form-data".
HTML
<form action="/search/" method="get">
  <input type="text" name="q">
  <input type="submit" value="Search">
</form>

B. Why Django abstracts forms

  • Elimination of duplication: without a Form class, field names appear in the template, the view's validation code and the error messages — three places to keep in sync.
  • Single source of truth: the field declaration age = forms.IntegerField(min_value=18) generates the widget, the coercion and the error message together.
  • Two families: forms.Form for arbitrary data (a search box, a contact form) and forms.ModelForm for data backed by a model, which infers fields from the model definition.

III. Using GET, POST and HTTP — request semantics and the HttpRequest object

A. The HTTP request/response cycle

Django's view receives an HttpRequest and must return an HttpResponse; the method verb determines how form data reaches it.

  • Request line: GET /search/?q=django HTTP/1.1 — verb, path, query string, protocol version.
  • Key HttpRequest attributes: request.method ('GET', 'POST'), request.GET and request.POST (both QueryDict objects), request.FILES, request.body (raw bytes), request.META (headers).
  • QueryDict is immutable: it is a dict-like object allowing repeated keys; use request.GET.getlist('tag') for ?tag=a&tag=b, and request.GET.get('q', '') to avoid MultiValueDictKeyError.

B. GET versus POST contrasted

  1. GET: parameters are appended to the URL as a query string after ?, e.g. /search/?q=django&page=2.
    • Semantics: safe and idempotent — it should only read state, never change it.
    • Practical limits: URL length capped by servers/browsers (commonly ~2000 characters); values are visible, logged by proxies, stored in browser history, and bookmarkable.
    • Correct uses: search queries, filters, pagination — anything a user should be able to share as a link.
  2. POST: parameters are placed in the request body, encoded as application/x-www-form-urlencoded (or multipart/form-data).
    • Semantics: non-idempotent — submitting twice creates two records.
    • Practical limits: no meaningful size limit; body is not visible in the URL, though it is not encrypted — only HTTPS provides confidentiality.
    • Correct uses: creating, updating or deleting data, logins, uploads.
  • Verb-branching view pattern: if request.method == 'POST': ... else: ... is the canonical Django structure for a page that both displays and accepts a form.

C. Other verbs in passing

  • GET/POST only from HTML forms: browsers cannot issue PUT, PATCH or DELETE from a plain <form>; those verbs reach Django via JavaScript fetch or REST clients, where request.POST is empty and request.body must be parsed.

IV. Building Forms Using Django — declarative fields, widgets, rendering

A. Declaring a form class

A form is a class whose attributes are field instances; declaration order becomes rendering order.

PYTHON
from django import forms

class ContactForm(forms.Form):
    subject = forms.CharField(max_length=100)
    email   = forms.EmailField(required=False, label='Your e-mail')
    message = forms.CharField(widget=forms.Textarea)
  • Common fields: CharField, EmailField, IntegerField, DecimalField, BooleanField, DateField, ChoiceField(choices=...), FileField, ModelChoiceField(queryset=...).
  • Shared arguments: required (default True), label, initial, help_text, widget, validators, error_messages.
  • Field vs widget: the field handles validation and Python type; the widget handles HTML. forms.CharField(widget=forms.PasswordInput) keeps the string validation but renders <input type="password">.

B. Rendering in templates

  • Django renders fields, not the <form> tag: you always write <form method="post">, {% csrf_token %} and the submit button yourself.
  • Whole-form helpers: {{ form.as_p }}, {{ form.as_div }} (Django 4.1+, now the default {{ form }}), {{ form.as_table }}, {{ form.as_ul }}.
  • Manual control: loop for per-field markup.
HTML
<form method="post">{% csrf_token %}
  {{ form.non_field_errors }}
  {% for field in form %}
    {{ field.errors }}{{ field.label_tag }}{{ field }}
    <small>{{ field.help_text }}</small>
  {% endfor %}
  <button type="submit">Send</button>
</form>

C. ModelForm

  • Purpose: avoid restating model fields in the form.
  • Declaration: an inner class Meta naming model and fields (use an explicit list, or exclude; fields = '__all__' is convenient but risks exposing fields).
  • form.save(): writes to the database and returns the instance; save(commit=False) returns an unsaved instance so you can set obj.author = request.user before saving.

V. Introduction to Cross Site Request Forgery (CSRF)

A. The attack mechanism

CSRF (also "session riding", XSRF) tricks a logged-in user's browser into issuing an unwanted state-changing request to a site that trusts it.

  • Root cause: browsers attach cookies automatically to any request to a domain, regardless of which page initiated it — so the session cookie authenticates the forged request.
  • Attack chain: the victim is authenticated at bank.com; they visit evil.com, which contains a hidden auto-submitting form pointing at bank.com/transfer/; the browser sends it with the victim's cookies; the transfer succeeds.
HTML
<!-- hosted on evil.com -->
<form action="https://bank.com/transfer/" method="post" id="f">
  <input type="hidden" name="to" value="attacker">
  <input type="hidden" name="amount" value="10000">
</form>
<script>document.getElementById('f').submit();</script>
  • What the attacker does not need: the ability to read the response. CSRF is a write attack, unlike XSS which can read data.
  • Why GET must be safe: if /delete/?id=5 mutates data, a mere <img src="..."> on any site triggers it.

B. Defence principle

  • Secret-token requirement: demand proof that the request came from a page the server itself served, via a token the attacker cannot read (blocked by the same-origin policy).
  • Complementary defences: SameSite=Lax cookies (Django's default) stop cookies on cross-site POSTs, and Origin/Referer checking on HTTPS.

VI. CSRF Support in Django

A. The middleware and the token

  • django.middleware.csrf.CsrfViewMiddleware: enabled by default in MIDDLEWARE; it rejects any POST, PUT, PATCH or DELETE lacking a valid token with HTTP 403 Forbidden ("CSRF verification failed").
  • {% csrf_token %}: renders a hidden input, <input type="hidden" name="csrfmiddlewaretoken" value="...">; the middleware compares it against the value in the csrftoken cookie (masked per-request to defeat BREACH).
  • RequestContext requirement: the tag only works when the response is rendered with a request-aware context, i.e. render(request, ...).
  • AJAX: send the token in the X-CSRFToken header, read from the cookie or from {{ csrf_token }}.

B. Decorators and exemptions

  • @csrf_exempt: disables the check for one view — used only for endpoints authenticated by another means (e.g. a signed webhook).
  • @csrf_protect: enforces the check on a single view when the middleware is disabled.
  • @requires_csrf_token: ensures the token is available in an error view.
  • @ensure_csrf_cookie: forces the cookie to be set on a GET, needed when a JavaScript app POSTs without ever rendering a Django form.
  • Related settings: CSRF_COOKIE_SECURE, CSRF_COOKIE_HTTPONLY, and CSRF_TRUSTED_ORIGINS (required for HTTPS deployments behind a proxy or on a different origin).

VII. Implementing POST Redirect in Django — the POST/Redirect/GET pattern

A. The problem and the pattern

Rendering a template directly in response to a POST leaves the browser's last request as a POST, so refreshing (F5) or pressing Back re-submits it and duplicates the record.

  • Rule: on successful POST, return a redirect (HTTP 302, or 303 See Other) so the browser's final request is an idempotent GET.
  • On failure: re-render the template with the bound form so errors and the user's typed values are preserved — do not redirect.
PYTHON
from django.shortcuts import render, redirect

def contact(request):
    if request.method == 'POST':
        form = ContactForm(request.POST)
        if form.is_valid():
            form.save()
            return redirect('contact_thanks')   # 302 → GET
    else:
        form = ContactForm()
    return render(request, 'contact.html', {'form': form})
  • Redirect helpers: redirect() accepts a URL name (reversed), a model instance (via get_absolute_url()) or a literal path; HttpResponseRedirect is the underlying 302 class, HttpResponsePermanentRedirect the 301.
  • Carrying feedback across the redirect: the messages framework, messages.success(request, 'Saved.'), since the new GET is a fresh request with no context.

VIII. Data Validation with Django Forms — the cleaning pipeline

A. Order of validation

is_valid() runs full_clean(), which processes each field and then the form as a whole; any raised ValidationError is collected rather than propagated.

  • Per field: to_python() coerces the string → validate() checks emptiness/type → run_validators() applies the validators list → clean_<fieldname>() if defined.
  • Whole form: clean() runs last, for rules spanning two fields.
  • Outputs: form.cleaned_data['email'] for valid values; form.errors['email'] (a list) and form.non_field_errors() for clean() errors.

B. Layers of validation

  1. Field arguments and built-in validators: max_length, min_value, EmailValidator, RegexValidator, MinLengthValidator — declarative, reusable, and the first choice.
  2. Custom methods: used when the rule needs a database lookup or another field's value.
PYTHON
def clean_username(self):
    name = self.cleaned_data['username']
    if User.objects.filter(username__iexact=name).exists():
        raise forms.ValidationError('Username already taken.')
    return name                     # must return the cleaned value

def clean(self):
    cleaned = super().clean()
    if cleaned.get('password') != cleaned.get('confirm'):
        raise forms.ValidationError('Passwords do not match.')
    return cleaned
  • Key contract: clean_<field>() must return the value, otherwise cleaned_data loses it.
  • In clean() use .get(): a field that failed its own validation is absent from cleaned_data, so cleaned['password'] would raise KeyError.
  • Error codes and localisation: ValidationError('Too small', code='min_value', params={'limit': 18}) supports translation and programmatic handling.

C. Applications and limitations

  • Form validation is not model validation: Model.full_clean() is not called by Model.save(), so constraints bypassed by direct ORM writes still need database-level guards (unique=True, CheckConstraint).
  • Race conditions: a uniqueness check in clean_username() can pass for two concurrent requests; the authoritative guarantee is the database's unique index, so catch IntegrityError as well.
  • File uploads: FileField validation must inspect request.FILES, and the form must be constructed as MyForm(request.POST, request.FILES).