Right, forms. The part of web development that makes you long for the sweet, sweet release of just writing raw HTML. But you can’t, because you need validation, and security, and to not have users injecting script tags into your database. That’s where Django’s form system comes in, and it’s one of the framework’s secret weapons. It handles the tedious, security-critical crap so you can focus on the actual logic of your app. Let’s get into it.

The Humble Django Form: More Than Just <input>

Think of a Django Form class not as an HTML form, but as a validation machine. Its primary job is to define what data we expect, how to validate it, and then how to display it. The HTML rendering is almost a side effect.

Here’s the simplest possible example. Let’s say we need a form for users to suggest new, utterly ridiculous feature ideas for our app.

# forms.py
from django import forms

class FeatureSuggestionForm(forms.Form):
    title = forms.CharField(max_length=100)
    description = forms.CharField(widget=forms.Textarea)
    urgency = forms.ChoiceField(
        choices=[
            ('low', 'Maybe next decade?'),
            ('medium', 'I guess?'),
            ('high', 'THE WORLD WILL END WITHOUT THIS!'),
        ]
    )
    is_public = forms.BooleanField(required=False)

We’ve just defined our data schema and validation rules. max_length=100? Handled. required=False? Handled. Now, let’s use this beast in a view.

The Form Dance in a View

Using a form in a view follows a predictable pattern. It’s a two-step: show the form (GET request), process the form (POST request). Get this pattern tattooed on your brain.

# views.py
from django.shortcuts import render, redirect
from .forms import FeatureSuggestionForm

def suggest_feature(request):
    # Did the user submit data? (POST request)
    if request.method == 'POST':
        # Populate the form with the user's data
        form = FeatureSuggestionForm(request.POST)
        # Check if it's all valid (this is where the magic happens)
        if form.is_valid():
            # Form data is clean! Do something.
            title = form.cleaned_data['title']
            description = form.cleaned_data['description']
            # ... save to a model, send an email, etc.
            return redirect('success_url')  # Always redirect after a successful POST!
    else:
        # It's a GET request, just show a blank form
        form = FeatureSuggestionForm()

    # If the form is invalid (or it's a GET), render the form with any errors
    return render(request, 'suggestion_form.html', {'form': form})

Notice form.cleaned_data? That’s your reward for passing validation. It’s a dictionary of data that has been cleaned and converted to Python types. It’s safe. You can trust it. Unlike request.POST which is a dirty, untrustworthy mess straight from the user.

Rendering the Form in a Template (Without Looking Ugly)

You could manually write every <input>, but why? Django can do it for you, though its default output is… functional. Let’s look at your options in suggestion_form.html:

<form method="post">
    {% csrf_token %} <!-- NON-NEGOTIABLE. Forget this and Django will slap you. -->

    <!-- Option 1: Just dump the whole form -->
    {{ form.as_p }}

    <!-- Option 2: More control, field by field -->
    <div class="field-wrapper">
        {{ form.title.errors }}
        <label for="{{ form.title.id_for_label }}">Title:</label>
        {{ form.title }}
    </div>

    <div class="field-wrapper">
        {{ form.description.errors }}
        <label for="{{ form.description.id_for_label }}">Description:</label>
        {{ form.description }}
    </div>

    <!-- Option 3: Even more control? Loop through it. -->
    {% for field in form %}
        <div class="field-wrapper">
            {{ field.errors }}
            {{ field.label_tag }} {{ field }}
            {% if field.help_text %}
                <p class="help">{{ field.help_text }}</p>
            {% endif %}
        </div>
    {% endfor %}

    <button type="submit">Suggest Brilliance</button>
</form>

The as_p() method wraps each field in a <p> tag. There’s also as_table() and as_ul(). They’re great for prototypes but you’ll almost always need the granular control of Option 2 or 3 for a real site.

ModelForms: Because You’re Lazy and Smart

Now, the main event. If you have a Django model, writing a form that maps to it is an act of profound repetition. Django hates repetition. Enter ModelForm.

Let’s say we have this model:

# models.py
from django.db import models

class FeatureSuggestion(models.Model):
    title = models.CharField(max_length=100)
    description = models.TextField()
    urgency = models.CharField(max_length=10, choices=[...]) # same choices as before
    is_public = models.BooleanField(default=False)
    submitted_date = models.DateTimeField(auto_now_add=True)

Our ModelForm becomes trivial:

# forms.py
from django.forms import ModelForm
from .models import FeatureSuggestion

class FeatureSuggestionModelForm(ModelForm):
    class Meta:
        model = FeatureSuggestion
        fields = ['title', 'description', 'urgency', 'is_public']
        # or exclude = ['submitted_date'] to include everything *except* a field

That’s it. This form now has all the validation rules defined by the model (max_length, required, etc.). The view logic is identical. The killer feature? Calling form.save().

# views.py
def suggest_feature(request):
    if request.method == 'POST':
        form = FeatureSuggestionModelForm(request.POST)
        if form.is_valid():
            # This one line creates and saves the model instance. Magic.
            new_suggestion = form.save()
            # Now you have a saved object, maybe do something with it?
            return redirect('detail_view', pk=new_suggestion.pk)
    # ... rest of the view is the same

When the Magic Isn’t Quite Enough: Customizing ModelForms

Sometimes the automatically generated field isn’t what you want. Maybe you want to change the widget or add help text. No problem. You just declare the field on the form yourself, overriding the default.

class FeatureSuggestionModelForm(ModelForm):
    # Let's make the description use a smaller textarea and have help text
    description = forms.CharField(
        widget=forms.Textarea(attrs={'rows': 4, 'cols': 40}),
        help_text="Try to keep it under 1000 words. We have short attention spans."
    )

    class Meta:
        model = FeatureSuggestion
        fields = '__all__' # A bold choice. Includes every field on the model.

The Gotchas (Because Of Course There Are Gotchas)

  1. The fields Meta attribute is NOT optional. After a certain version, Django will scream at you if you don’t explicitly set fields or exclude in your ModelForm.Meta. This is a security feature to prevent you from accidentally exposing every field in your model, including user.is_admin. Always be explicit.

  2. That required attribute. Django sets the HTML required attribute based on your model/form field. If you have blank=True on your model field, it won’t be required in the form. This is usually what you want, but be aware.

  3. Save with commit=False. This is your superpower. form.save(commit=False) creates the model instance but doesn’t send it to the database. Why? So you can add data that isn’t in the form, like setting the user field to the current request.user.

    if form.is_valid():
        new_suggestion = form.save(commit=False)
        new_suggestion.user = request.user # Set the user who submitted it
        new_suggestion.save() # NOW it gets saved to the DB.
    

The form system is deep. You can write custom validators, clean specific fields, clean the form as a whole, and more. But this is the 90%. Master this, and you’ve already lapped everyone trying to hand-roll their form validation.