Introduction to Django Forms

This article introduces Django forms and explains how to collect, validate, clean, and process user input. It covers Form, ModelForm, fields, widgets, validation, cleaned_data, GET and POST handling, file uploads, CSRF protection, model creation and updates, class-based view integration, testing, and common mistakes.

Introduction to Django Forms

Forms are one of the main ways users send data to a Django application.

A form may be used to:

  • create an account
  • log in
  • submit a contact message
  • create a blog post
  • update a profile
  • upload a file
  • search for records
  • select application settings

HTML forms can collect data in the browser, but Django provides a form system that handles much more than rendering <input> elements.

Django forms can:

  • define fields
  • validate submitted values
  • convert raw strings into Python values
  • display validation errors
  • generate HTML controls
  • clean and normalize data
  • work with model instances
  • protect against invalid input

A basic Django form sits between the browser and the application logic.

text

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
Browser
   ↓
HTML form submission
   ↓
Django Form
   ↓
Validation and cleaning
   ↓
Python data
   ↓
Application logic or database

What Is a Django Form?

A Django form is a Python class that describes a set of input fields and the rules associated with them.

A simple form might look like this:

python

1
2
3
4
5
6
7
8
9
from django import forms


class ContactForm(forms.Form):
    name = forms.CharField(max_length=100)
    email = forms.EmailField()
    message = forms.CharField(
        widget=forms.Textarea,
    )

This form defines three fields:

text

1
2
3
name
email
message

Django knows how to:

  • render suitable HTML inputs
  • check that required values exist
  • validate the email address
  • enforce the maximum length
  • convert submitted values into cleaned Python data
  • display errors when validation fails

The Two Main Types of Django Forms

Django commonly uses two form classes:

text

1
2
forms.Form
forms.ModelForm

Use forms.Form when the form is not directly tied to a database model.

Use forms.ModelForm when the form creates or updates a model instance.

Examples:

text

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
forms.Form
    Contact form
    Search form
    Login-like custom form
    Filter form
    Multi-step workflow

forms.ModelForm
    Create article
    Edit profile
    Add product
    Update customer

A Basic Form

Create a form in an app’s forms.py file:

text

1
2
3
4
5
contact/
├── forms.py
├── views.py
├── urls.py
└── templates/

Example:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
from django import forms


class ContactForm(forms.Form):
    name = forms.CharField(
        max_length=100,
    )

    email = forms.EmailField()

    subject = forms.CharField(
        max_length=200,
    )

    message = forms.CharField(
        widget=forms.Textarea,
    )

Each form field is an instance of a Django form-field class.

Common Form Fields

Field Purpose
CharField Text
EmailField Email address
IntegerField Whole number
DecimalField Decimal number
BooleanField True/false value
DateField Date
DateTimeField Date and time
ChoiceField One value from choices
MultipleChoiceField Several values from choices
FileField Uploaded file
ImageField Uploaded image
URLField URL
UUIDField UUID value

Example:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class RegistrationForm(forms.Form):
    username = forms.CharField(
        max_length=100,
    )

    age = forms.IntegerField(
        min_value=18,
    )

    email = forms.EmailField()

    agree_to_terms = forms.BooleanField()

Form Fields and Model Fields Are Different

Django form fields and Django model fields have similar names, but they serve different purposes.

Model field:

python

1
2
3
4
5
6
from django.db import models


title = models.CharField(
    max_length=200,
)

Form field:

python

1
2
3
4
5
6
from django import forms


title = forms.CharField(
    max_length=200,
)

A model field describes database data.

A form field describes user input and validation.

text

1
2
3
4
5
Model field
    Database structure

Form field
    Input and validation

A ModelForm connects the two systems automatically.

Rendering a Form in a Template

A Django form object can generate HTML.

View:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
from django.shortcuts import render

from .forms import ContactForm


def contact(request):
    form = ContactForm()

    return render(
        request,
        "contact/contact.html",
        {"form": form},
    )

Template:

django

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<h1>Contact</h1>

<form method="post">
    {% csrf_token %}

    {{ form.as_p }}

    <button type="submit">
        Send
    </button>
</form>

form.as_p renders each field inside a paragraph.

Generated HTML may resemble:

html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<p>
    <label for="id_name">Name:</label>
    <input
        type="text"
        name="name"
        maxlength="100"
        required
        id="id_name"
    >
</p>

Form Rendering Shortcuts

Django provides several rendering shortcuts.

django

1
{{ form.as_p }}

Renders fields inside <p> elements.

django

1
{{ form.as_ul }}

Renders fields as list items.

django

1
{{ form.as_table }}

Renders fields as table rows.

For greater control, render fields manually:

django

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
<form method="post">
    {% csrf_token %}

    <div>
        {{ form.name.label_tag }}
        {{ form.name }}
        {{ form.name.errors }}
    </div>

    <div>
        {{ form.email.label_tag }}
        {{ form.email }}
        {{ form.email.errors }}
    </div>

    <button type="submit">
        Send
    </button>
</form>

Manual rendering is useful when the form needs custom layout or styling.

GET and POST

Forms commonly use either GET or POST.

Use GET when the form retrieves or filters information.

Examples:

  • search
  • filters
  • sorting

Use POST when the form changes server-side data.

Examples:

  • creating an object
  • editing an object
  • registering a user
  • submitting a message
  • uploading a file

Search form:

html

1
<form method="get">

Create form:

html

1
<form method="post">

Processing a POST Request

A typical form view handles both the initial page request and the submitted form.

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from django.shortcuts import render

from .forms import ContactForm


def contact(request):
    if request.method == "POST":
        form = ContactForm(request.POST)

        if form.is_valid():
            name = form.cleaned_data["name"]
            email = form.cleaned_data["email"]
            message = form.cleaned_data["message"]

            # Process the valid data.
    else:
        form = ContactForm()

    return render(
        request,
        "contact/contact.html",
        {"form": form},
    )

The two main states are:

text

1
2
3
4
5
GET
    Create an empty form.

POST
    Bind submitted data to the form.

Bound and Unbound Forms

An unbound form has no submitted data:

python

1
form = ContactForm()

A bound form contains submitted data:

python

1
form = ContactForm(request.POST)

An unbound form is normally used for initial display.

A bound form can be validated.

Check:

python

1
form.is_bound

Example:

python

1
2
3
form = ContactForm()

print(form.is_bound)

returns:

text

1
False

While:

python

1
2
3
4
5
6
7
form = ContactForm(
    {
        "name": "Alex",
        "email": "alex@example.com",
        "message": "Hello",
    }
)

has:

text

1
True

is_valid()

Call:

python

1
form.is_valid()

to run validation.

Example:

python

1
2
if form.is_valid():
    ...

If every field is valid:

python

1
form.is_valid()

returns:

text

1
True

If one or more fields are invalid:

text

1
False

Django then stores the validation errors on the form.

cleaned_data

After a successful call to is_valid(), use:

python

1
form.cleaned_data

Example:

python

1
2
if form.is_valid():
    email = form.cleaned_data["email"]

cleaned_data contains validated Python values.

For example, raw submitted data may contain:

text

1
"25"

but an IntegerField converts it into:

python

1
25

Likewise, a date string may become a Python date object.

This is one of the most important purposes of a Django form:

text

1
2
3
4
5
Raw browser strings
        ↓
Validation
        ↓
Clean Python values

Do not use request.POST as if it has already been validated.

Prefer:

python

1
form.cleaned_data["email"]

after validation.

Validation Errors

When validation fails, the form contains errors.

python

1
2
if not form.is_valid():
    print(form.errors)

For example:

python

1
2
3
4
5
6
7
form = ContactForm(
    {
        "name": "",
        "email": "invalid",
        "message": "",
    }
)

may produce errors for:

text

1
2
3
name
email
message

Django automatically redisplays bound form values and validation messages when the same form is rendered again.

Displaying Errors

With:

django

1
{{ form.as_p }}

errors are normally rendered automatically.

When rendering manually:

django

1
2
3
{{ form.email.errors }}

{{ form.email }}

Display all non-field errors with:

django

1
{{ form.non_field_errors }}

You can also render all errors:

django

1
{{ form.errors }}

Required Fields

Form fields are required by default.

python

1
name = forms.CharField()

means a value is required.

Make a field optional with:

python

1
2
3
phone = forms.CharField(
    required=False,
)

Example:

python

1
2
3
4
5
6
7
8
9
class ContactForm(forms.Form):
    name = forms.CharField(
        max_length=100,
    )

    phone = forms.CharField(
        max_length=30,
        required=False,
    )

Initial Values

Use initial to give a field a starting value.

python

1
2
3
4
class ContactForm(forms.Form):
    subject = forms.CharField(
        initial="General question",
    )

You can also provide initial values when creating the form:

python

1
2
3
4
5
6
form = ContactForm(
    initial={
        "name": "Alex",
        "subject": "Support",
    }
)

This is useful when:

  • editing existing data
  • pre-filling known values
  • providing sensible defaults

Initial values are not the same as submitted data.

Labels

Django generates labels automatically from field names.

python

1
first_name = forms.CharField()

normally becomes:

text

1
First name

Set a custom label:

python

1
2
3
first_name = forms.CharField(
    label="Your first name",
)

Help Text

Use help_text to provide instructions.

python

1
2
3
4
5
6
username = forms.CharField(
    max_length=100,
    help_text=(
        "Use letters, numbers, and underscores."
    ),
)

In templates, help text may appear alongside the field.

Manual rendering:

django

1
2
3
4
5
6
7
{{ form.username }}

{% if form.username.help_text %}
    <p>
        {{ form.username.help_text }}
    </p>
{% endif %}

Widgets

A form field determines validation.

A widget determines how the field is rendered in HTML.

Example:

python

1
2
3
message = forms.CharField(
    widget=forms.Textarea,
)

The field is still a CharField.

But it renders as:

html

1
<textarea></textarea>

instead of:

html

1
<input type="text">

A useful distinction is:

text

1
2
3
4
5
Field
    Validation and Python value

Widget
    HTML representation

Common Widgets

Widget HTML control
TextInput Text input
Textarea Text area
PasswordInput Password input
EmailInput Email input
NumberInput Number input
CheckboxInput Checkbox
Select Drop-down
RadioSelect Radio buttons
CheckboxSelectMultiple Multiple checkboxes
DateInput Date-like input
FileInput File upload

Example:

python

1
2
3
password = forms.CharField(
    widget=forms.PasswordInput,
)

Customizing Widget Attributes

HTML attributes can be added through attrs.

python

1
2
3
4
5
6
7
8
name = forms.CharField(
    widget=forms.TextInput(
        attrs={
            "class": "form-control",
            "placeholder": "Your name",
        }
    )
)

Generated HTML may resemble:

html

1
2
3
4
5
<input
    type="text"
    class="form-control"
    placeholder="Your name"
>

Another example:

python

1
2
3
4
5
6
7
8
message = forms.CharField(
    widget=forms.Textarea(
        attrs={
            "rows": 6,
            "placeholder": "Your message",
        }
    )
)

ChoiceField

Use ChoiceField when a user must select one value from a predefined list.

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
SUBJECT_CHOICES = [
    ("support", "Support"),
    ("sales", "Sales"),
    ("general", "General question"),
]


class ContactForm(forms.Form):
    subject = forms.ChoiceField(
        choices=SUBJECT_CHOICES,
    )

Django normally renders this as a <select>.

Stored value:

text

1
support

Displayed label:

text

1
Support

Radio Buttons

Use RadioSelect:

python

1
2
3
4
5
6
7
8
priority = forms.ChoiceField(
    choices=[
        ("low", "Low"),
        ("medium", "Medium"),
        ("high", "High"),
    ],
    widget=forms.RadioSelect,
)

The validation remains that of a ChoiceField.

Only the widget changes.

Multiple Choices

Use MultipleChoiceField:

python

1
2
3
4
5
6
7
topics = forms.MultipleChoiceField(
    choices=[
        ("django", "Django"),
        ("python", "Python"),
        ("react", "React"),
    ],
)

A valid result may be:

python

1
2
3
4
[
    "django",
    "python",
]

Use checkboxes:

python

1
2
3
4
topics = forms.MultipleChoiceField(
    choices=TOPIC_CHOICES,
    widget=forms.CheckboxSelectMultiple,
)

Boolean Fields

A Boolean field commonly renders as a checkbox.

python

1
agree_to_terms = forms.BooleanField()

It is required by default.

An optional Boolean field can be:

python

1
2
3
subscribe = forms.BooleanField(
    required=False,
)

Custom Field Validation

Django provides several ways to add custom validation.

For one field, define:

text

1
clean_<field_name>()

Example:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
from django import forms


class RegistrationForm(forms.Form):
    username = forms.CharField(
        max_length=100,
    )

    def clean_username(self):
        username = self.cleaned_data["username"]

        if " " in username:
            raise forms.ValidationError(
                "Username cannot contain spaces."
            )

        return username

The method must return the cleaned value.

Field Validation Flow

For a field named:

text

1
username

Django performs validation and then calls:

python

1
clean_username()

if the earlier field validation succeeds.

A simplified flow is:

text

1
2
3
4
5
6
7
8
9
Raw value
   ↓
Field validation
   ↓
Field conversion
   ↓
clean_username()
   ↓
cleaned_data

Cross-Field Validation

Sometimes validation depends on more than one field.

Use the form’s:

python

1
clean()

method.

Example:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class RegistrationForm(forms.Form):
    password = forms.CharField(
        widget=forms.PasswordInput,
    )

    confirm_password = forms.CharField(
        widget=forms.PasswordInput,
    )

    def clean(self):
        cleaned_data = super().clean()

        password = cleaned_data.get("password")
        confirm_password = cleaned_data.get(
            "confirm_password"
        )

        if (
            password
            and confirm_password
            and password != confirm_password
        ):
            raise forms.ValidationError(
                "Passwords do not match."
            )

        return cleaned_data

This creates a form-level error rather than an error attached to one specific field.

Adding an Error to a Specific Field

Inside clean() you can attach an error manually:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
def clean(self):
    cleaned_data = super().clean()

    start = cleaned_data.get("start_date")
    end = cleaned_data.get("end_date")

    if start and end and end < start:
        self.add_error(
            "end_date",
            "End date must be after start date.",
        )

    return cleaned_data

This often gives a better user experience than a generic non-field error.

Validators

Reusable validators can be attached to form fields.

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
from django.core.validators import (
    MinLengthValidator,
)


username = forms.CharField(
    validators=[
        MinLengthValidator(5),
    ]
)

Custom validator:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
from django.core.exceptions import (
    ValidationError,
)


def validate_even(value):
    if value % 2:
        raise ValidationError(
            "The value must be even."
        )

Use it:

python

1
2
3
number = forms.IntegerField(
    validators=[validate_even],
)

Validators are useful when the same rule needs to be reused.

A Complete Basic Form View

Form:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
from django import forms


class ContactForm(forms.Form):
    name = forms.CharField(
        max_length=100,
    )

    email = forms.EmailField()

    message = forms.CharField(
        widget=forms.Textarea,
    )

View:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
from django.shortcuts import (
    redirect,
    render,
)

from .forms import ContactForm


def contact(request):
    if request.method == "POST":
        form = ContactForm(request.POST)

        if form.is_valid():
            name = form.cleaned_data["name"]
            email = form.cleaned_data["email"]
            message = form.cleaned_data["message"]

            # Process the message here.

            return redirect("contact-success")
    else:
        form = ContactForm()

    return render(
        request,
        "contact/contact.html",
        {"form": form},
    )

Template:

django

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<h1>Contact</h1>

<form method="post">
    {% csrf_token %}

    {{ form.as_p }}

    <button type="submit">
        Send message
    </button>
</form>

Why Redirect After a Successful POST?

After successfully processing a form, redirect to another URL:

python

1
return redirect("contact-success")

This follows the common:

text

1
2
3
4
5
6
7
POST
   ↓
Process
   ↓
Redirect
   ↓
GET

pattern.

It is often called Post/Redirect/Get.

Without a redirect, refreshing the page may cause the browser to ask whether the user wants to resubmit the form.

For actions such as:

  • creating records
  • sending emails
  • placing orders

accidental resubmission can cause duplicate actions.

ModelForm

A ModelForm creates form fields from a Django model.

Consider:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
from django.db import models


class Article(models.Model):
    title = models.CharField(
        max_length=200,
    )

    content = models.TextField()

    is_published = models.BooleanField(
        default=False,
    )

    def __str__(self):
        return self.title

A corresponding model form can be:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
from django import forms

from .models import Article


class ArticleForm(forms.ModelForm):
    class Meta:
        model = Article
        fields = [
            "title",
            "content",
            "is_published",
        ]

Django generates suitable form fields based on the model fields.

The Meta Class

A ModelForm uses an inner Meta class.

python

1
2
3
4
5
6
class Meta:
    model = Article
    fields = [
        "title",
        "content",
    ]

model tells Django which model the form belongs to.

fields tells Django which fields the user may edit.

Explicitly List Editable Fields

Prefer:

python

1
2
3
4
fields = [
    "title",
    "content",
]

over:

python

1
fields = "__all__"

when users should not be able to edit every model field.

Explicit field lists make it easier to see which data can be submitted.

This is especially important for fields such as:

  • owner
  • author
  • permissions
  • status
  • internal flags
  • prices
  • moderation fields

Do not expose sensitive model fields merely because they exist on the model.

Creating an Object with ModelForm

View:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from django.shortcuts import (
    redirect,
    render,
)

from .forms import ArticleForm


def article_create(request):
    if request.method == "POST":
        form = ArticleForm(request.POST)

        if form.is_valid():
            article = form.save()

            return redirect(
                "article-detail",
                pk=article.pk,
            )
    else:
        form = ArticleForm()

    return render(
        request,
        "articles/article_form.html",
        {"form": form},
    )

form.save() creates and saves the model instance.

Updating an Existing Object

Pass the existing instance into the form.

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
from django.shortcuts import (
    get_object_or_404,
    redirect,
    render,
)

from .forms import ArticleForm
from .models import Article


def article_update(request, pk):
    article = get_object_or_404(
        Article,
        pk=pk,
    )

    if request.method == "POST":
        form = ArticleForm(
            request.POST,
            instance=article,
        )

        if form.is_valid():
            article = form.save()

            return redirect(
                "article-detail",
                pk=article.pk,
            )
    else:
        form = ArticleForm(
            instance=article,
        )

    return render(
        request,
        "articles/article_form.html",
        {"form": form},
    )

The important argument is:

python

1
instance=article

Without it, the form would create a new article instead of updating the existing one.

save(commit=False)

Sometimes the view must set model fields that are not exposed in the form.

Suppose:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class Article(models.Model):
    title = models.CharField(
        max_length=200,
    )

    content = models.TextField()

    author = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
    )

The form should not let users select arbitrary authors:

python

1
2
3
4
5
6
7
class ArticleForm(forms.ModelForm):
    class Meta:
        model = Article
        fields = [
            "title",
            "content",
        ]

In the view:

python

1
2
3
4
5
6
7
if form.is_valid():
    article = form.save(
        commit=False,
    )

    article.author = request.user
    article.save()

commit=False creates the model instance without saving it to the database immediately.

This gives the application a chance to set additional values.

Many-to-Many Fields and save_m2m()

When a ModelForm contains many-to-many fields and you use:

python

1
form.save(commit=False)

the many-to-many relationships cannot be saved until the main object has a primary key.

Example:

python

1
2
3
4
5
6
7
8
article = form.save(
    commit=False,
)

article.author = request.user
article.save()

form.save_m2m()

Use:

python

1
form.save_m2m()

after saving the model instance.

Customizing a ModelForm

You can customize generated fields directly.

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
class ArticleForm(forms.ModelForm):
    class Meta:
        model = Article
        fields = [
            "title",
            "content",
        ]

        widgets = {
            "title": forms.TextInput(
                attrs={
                    "class": "form-control",
                }
            ),
            "content": forms.Textarea(
                attrs={
                    "rows": 10,
                }
            ),
        }

Other Meta options include:

python

1
2
3
labels = {
    "title": "Article title",
}
python

1
2
3
help_texts = {
    "title": "Enter a clear title.",
}
python

1
2
3
4
5
error_messages = {
    "title": {
        "required": "Please provide a title.",
    },
}

Adding Non-Model Fields to a ModelForm

A ModelForm can contain additional fields that do not exist on the model.

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class ArticleForm(forms.ModelForm):
    confirm_publish = forms.BooleanField(
        required=False,
    )

    class Meta:
        model = Article
        fields = [
            "title",
            "content",
            "is_published",
        ]

The extra field appears in:

python

1
form.cleaned_data

but it is not automatically saved to the model.

This is useful for:

  • confirmations
  • temporary options
  • workflow decisions
  • additional validation

File Upload Forms

Forms that accept files require:

html

1
enctype="multipart/form-data"

Example:

django

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<form
    method="post"
    enctype="multipart/form-data"
>
    {% csrf_token %}

    {{ form.as_p }}

    <button type="submit">
        Upload
    </button>
</form>

View:

python

1
2
3
4
form = DocumentForm(
    request.POST,
    request.FILES,
)

Both pieces are required:

text

1
2
3
4
5
HTML:
    multipart/form-data

Django:
    request.FILES

CSRF Protection

POST forms should normally include:

django

1
{% csrf_token %}

Example:

django

1
2
3
4
5
6
7
8
<form method="post">
    {% csrf_token %}
    {{ form.as_p }}

    <button type="submit">
        Save
    </button>
</form>

Django uses CSRF protection to help prevent another website from submitting unwanted requests using a user’s browser session.

Do not remove CSRF protection merely to make a form submission work.

Form Prefixes

Sometimes one page contains multiple forms with fields that have the same names.

Use prefixes:

python

1
2
3
4
5
6
7
contact_form = ContactForm(
    prefix="contact",
)

newsletter_form = NewsletterForm(
    prefix="newsletter",
)

Generated names may become:

text

1
2
contact-email
newsletter-email

On POST:

python

1
2
3
4
contact_form = ContactForm(
    request.POST,
    prefix="contact",
)

The same prefix must be used when binding submitted data.

Hidden Fields

Use a hidden widget when a field should be submitted but not displayed as a normal control.

python

1
2
3
tracking_id = forms.CharField(
    widget=forms.HiddenInput,
)

Remember that hidden form values are still controlled by the browser.

A user can modify them.

Do not trust hidden input for:

  • permissions
  • ownership
  • prices
  • security decisions

Sensitive values should be determined server-side.

Disabled Fields

A field can be disabled:

python

1
2
3
username = forms.CharField(
    disabled=True,
)

Django renders the field as disabled and ignores submitted modifications to it.

This is more secure than relying only on an HTML disabled attribute added manually because Django also enforces the behavior during form processing.

Form Inheritance

Forms are Python classes, so they can use inheritance.

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class BaseContactForm(forms.Form):
    name = forms.CharField(
        max_length=100,
    )
    email = forms.EmailField()


class SupportForm(BaseContactForm):
    issue = forms.CharField(
        widget=forms.Textarea,
    )

Inheritance can reduce repetition, but avoid creating deep form hierarchies that make field behavior difficult to understand.

Formsets

Sometimes a page needs to process several similar forms at once.

Examples:

  • several invoice items
  • several phone numbers
  • several related records

Django provides formsets for this purpose.

Basic example:

python

1
2
3
4
5
6
7
from django.forms import formset_factory


PhoneFormSet = formset_factory(
    PhoneForm,
    extra=3,
)

A formset manages a collection of related forms.

For beginner applications, start with ordinary forms first. Learn formsets when a page genuinely needs repeated forms.

Model Formsets

For multiple instances of the same model, Django provides model formsets.

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
from django.forms import modelformset_factory

from .models import Article


ArticleFormSet = modelformset_factory(
    Article,
    fields=[
        "title",
        "is_published",
    ],
    extra=0,
)

This can support bulk editing.

Inline Formsets

Inline formsets are useful for parent-child relationships.

For example:

text

1
2
3
4
Order
    OrderItem
    OrderItem
    OrderItem

An inline formset can allow editing the order and several related items on one page.

This is a more advanced form topic and should be introduced only when the application needs it.

Form Classes and Class-Based Views

Django’s generic class-based views can work with forms.

Example:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
from django.views.generic import FormView

from .forms import ContactForm


class ContactView(FormView):
    template_name = "contact/contact.html"
    form_class = ContactForm
    success_url = "/thanks/"

    def form_valid(self, form):
        name = form.cleaned_data["name"]

        # Process the valid form.

        return super().form_valid(form)

For model forms, views such as:

text

1
2
CreateView
UpdateView

can generate or use model forms automatically.

Using a Custom Form with CreateView

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
from django.views.generic import CreateView

from .forms import ArticleForm
from .models import Article


class ArticleCreateView(CreateView):
    model = Article
    form_class = ArticleForm
    template_name = "articles/article_form.html"

Do not normally define both:

python

1
form_class

and:

python

1
fields

on the same generic editing view.

Choose one approach.

Customizing Form Behavior in Class-Based Views

Set server-controlled values in:

python

1
form_valid()

Example:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class ArticleCreateView(
    LoginRequiredMixin,
    CreateView,
):
    model = Article
    form_class = ArticleForm

    def form_valid(self, form):
        form.instance.author = (
            self.request.user
        )

        return super().form_valid(form)

This keeps author out of the browser-submitted fields.

Passing Extra Data to a Form

Sometimes a form needs access to information such as the current user.

Custom form:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class ArticleForm(forms.ModelForm):
    def __init__(
        self,
        *args,
        user=None,
        **kwargs,
    ):
        super().__init__(
            *args,
            **kwargs,
        )

        self.user = user

Function-based view:

python

1
2
3
4
form = ArticleForm(
    request.POST or None,
    user=request.user,
)

In a class-based view, override get_form_kwargs():

python

1
2
3
4
5
def get_form_kwargs(self):
    kwargs = super().get_form_kwargs()
    kwargs["user"] = self.request.user

    return kwargs

This is useful when:

  • choices depend on the user
  • validation depends on permissions
  • querysets need to be user-specific

Dynamic Querysets

Suppose a form contains a model choice:

python

1
2
3
4
5
6
7
class ArticleForm(forms.ModelForm):
    class Meta:
        model = Article
        fields = [
            "title",
            "category",
        ]

You can limit its queryset:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
class ArticleForm(forms.ModelForm):
    def __init__(
        self,
        *args,
        user=None,
        **kwargs,
    ):
        super().__init__(
            *args,
            **kwargs,
        )

        if user is not None:
            self.fields[
                "category"
            ].queryset = (
                Category.objects.filter(
                    owner=user,
                )
            )

This can prevent users from selecting objects they do not own.

Still enforce permissions server-side when saving or processing the result.

Empty Values

Different form fields normalize empty input in different ways.

For a normal optional text field:

python

1
2
3
nickname = forms.CharField(
    required=False,
)

an empty value is commonly:

python

1
""

For other fields, the cleaned value may be:

python

1
None

Do not assume all empty inputs have the same cleaned representation.

Check the behavior of the field type being used.

Error Messages

Customize field errors:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
email = forms.EmailField(
    error_messages={
        "required": (
            "Please enter your email address."
        ),
        "invalid": (
            "Enter a valid email address."
        ),
    }
)

This can make forms easier for users to understand.

Avoid exposing internal exceptions or implementation details as validation messages.

Checking Whether a Form Changed

When editing existing data, use:

python

1
form.has_changed()

Example:

python

1
2
3
if form.is_valid():
    if form.has_changed():
        form.save()

See changed fields:

python

1
form.changed_data

Example result:

python

1
2
3
4
[
    "title",
    "content",
]

Cleaning and Normalizing Input

Validation can also normalize user input.

Example:

python

1
2
3
4
def clean_email(self):
    email = self.cleaned_data["email"]

    return email.strip().lower()

Or:

python

1
2
3
4
def clean_title(self):
    title = self.cleaned_data["title"]

    return title.strip()

Be careful not to perform unexpected transformations that change meaningful user data.

Avoid Putting Too Much Logic in Forms

Forms are a good place for:

  • input validation
  • field-specific normalization
  • cross-field validation

Forms are usually not the best place for:

  • complex payment workflows
  • large business processes
  • sending several external requests
  • multi-step database operations
  • unrelated application logic

For example, this may become difficult to maintain:

python

1
2
3
4
5
6
7
def clean(self):
    # Validate inventory.
    # Charge payment.
    # Send email.
    # Update analytics.
    # Create shipping label.
    ...

Prefer a service function for the main business operation.

python

1
2
3
4
5
if form.is_valid():
    create_order(
        user=request.user,
        data=form.cleaned_data,
    )

Validation and business workflow are related, but they are not the same responsibility.

Forms Do Not Replace Database Constraints

A form may validate uniqueness:

python

1
2
class ArticleForm(forms.ModelForm):
    ...

but another part of the application may create records without using that form.

Important data rules should also be enforced at the appropriate lower level, such as:

  • model constraints
  • unique fields
  • database constraints

Forms improve input validation and user feedback.

They should not be the only protection for important data integrity rules.

Forms Do Not Replace Permissions

A form can hide a field:

python

1
2
3
4
fields = [
    "title",
    "content",
]

but that does not prove the user is allowed to edit the object.

The view must still enforce authorization.

Example:

python

1
2
3
4
5
article = get_object_or_404(
    Article,
    pk=pk,
    author=request.user,
)

Then:

python

1
2
3
4
form = ArticleForm(
    request.POST or None,
    instance=article,
)

The form validates data.

The view controls access.

Testing Forms

Django forms are easy to test directly.

Valid form:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
from django.test import TestCase

from .forms import ContactForm


class ContactFormTests(TestCase):
    def test_valid_form(self):
        form = ContactForm(
            data={
                "name": "Alex",
                "email": "alex@example.com",
                "message": "Hello",
            }
        )

        self.assertTrue(
            form.is_valid()
        )

Invalid form:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
def test_invalid_email(self):
    form = ContactForm(
        data={
            "name": "Alex",
            "email": "invalid-email",
            "message": "Hello",
        }
    )

    self.assertFalse(
        form.is_valid()
    )

    self.assertIn(
        "email",
        form.errors,
    )

Testing Custom Validation

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
def test_username_cannot_contain_spaces(self):
    form = RegistrationForm(
        data={
            "username": "alex smith",
        }
    )

    self.assertFalse(
        form.is_valid()
    )

    self.assertIn(
        "username",
        form.errors,
    )

Test important boundary values as well as the normal case.

Common Beginner Mistakes

Forgetting to Call is_valid()

Do not use:

python

1
form.cleaned_data

before validation.

Correct:

python

1
2
if form.is_valid():
    data = form.cleaned_data

Reading Raw POST Data Instead of Cleaned Data

Avoid:

python

1
email = request.POST["email"]

after creating a form.

Prefer:

python

1
email = form.cleaned_data["email"]

after validation.

Creating a New Form After Validation Fails

Incorrect:

python

1
2
3
4
5
if request.method == "POST":
    form = ContactForm(request.POST)

    if not form.is_valid():
        form = ContactForm()

This discards submitted data and errors.

Keep the bound form:

python

1
2
if request.method == "POST":
    form = ContactForm(request.POST)

and render it again if invalid.

Forgetting CSRF Protection

For normal POST forms:

django

1
{% csrf_token %}

should be inside the form.

Forgetting multipart/form-data

Required for file uploads:

html

1
2
3
4
<form
    method="post"
    enctype="multipart/form-data"
>

Forgetting request.FILES

For file forms:

python

1
2
3
4
form = UploadForm(
    request.POST,
    request.FILES,
)

Using fields = "__all__" Carelessly

This may accidentally expose fields users should not control.

Prefer an explicit field list.

Forgetting instance During Updates

Incorrect:

python

1
form = ArticleForm(request.POST)

when editing an existing article.

Correct:

python

1
2
3
4
form = ArticleForm(
    request.POST,
    instance=article,
)

Forgetting save_m2m()

After:

python

1
2
obj = form.save(commit=False)
obj.save()

call:

python

1
form.save_m2m()

if the form includes many-to-many fields.

Putting Authorization in Hidden Fields

Do not trust:

html

1
2
3
4
5
<input
    type="hidden"
    name="owner_id"
    value="42"
>

to control ownership.

Set ownership server-side.

Treating Browser Validation as Security

HTML attributes such as:

html

1
2
3
required
min
max

improve the interface, but a client can bypass them.

Django must still validate the submitted data.

A Complete ModelForm Example

Model:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
from django.conf import settings
from django.db import models


class Article(models.Model):
    title = models.CharField(
        max_length=200,
    )

    content = models.TextField()

    author = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
    )

    is_published = models.BooleanField(
        default=False,
    )

    created_at = models.DateTimeField(
        auto_now_add=True,
    )

    def __str__(self):
        return self.title

Form:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
from django import forms

from .models import Article


class ArticleForm(forms.ModelForm):
    class Meta:
        model = Article
        fields = [
            "title",
            "content",
            "is_published",
        ]

        widgets = {
            "title": forms.TextInput(
                attrs={
                    "placeholder": (
                        "Article title"
                    ),
                }
            ),
            "content": forms.Textarea(
                attrs={
                    "rows": 10,
                }
            ),
        }

    def clean_title(self):
        title = self.cleaned_data[
            "title"
        ].strip()

        if len(title) < 5:
            raise forms.ValidationError(
                "The title must contain "
                "at least five characters."
            )

        return title

Create view:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
from django.contrib.auth.decorators import (
    login_required,
)
from django.shortcuts import (
    redirect,
    render,
)

from .forms import ArticleForm


@login_required
def article_create(request):
    if request.method == "POST":
        form = ArticleForm(
            request.POST,
        )

        if form.is_valid():
            article = form.save(
                commit=False,
            )

            article.author = request.user
            article.save()

            return redirect(
                "article-detail",
                pk=article.pk,
            )
    else:
        form = ArticleForm()

    return render(
        request,
        "articles/article_form.html",
        {"form": form},
    )

Template:

django

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<h1>Create Article</h1>

<form method="post">
    {% csrf_token %}

    {{ form.non_field_errors }}

    <div>
        {{ form.title.label_tag }}
        {{ form.title }}
        {{ form.title.errors }}
    </div>

    <div>
        {{ form.content.label_tag }}
        {{ form.content }}
        {{ form.content.errors }}
    </div>

    <div>
        {{ form.is_published }}
        {{ form.is_published.label_tag }}
        {{ form.is_published.errors }}
    </div>

    <button type="submit">
        Save article
    </button>
</form>

Update view:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
from django.shortcuts import (
    get_object_or_404,
)


@login_required
def article_update(request, pk):
    article = get_object_or_404(
        Article,
        pk=pk,
        author=request.user,
    )

    if request.method == "POST":
        form = ArticleForm(
            request.POST,
            instance=article,
        )

        if form.is_valid():
            article = form.save()

            return redirect(
                "article-detail",
                pk=article.pk,
            )
    else:
        form = ArticleForm(
            instance=article,
        )

    return render(
        request,
        "articles/article_form.html",
        {"form": form},
    )

This setup demonstrates:

  • model-backed forms
  • field validation
  • widgets
  • creation
  • updating
  • ownership protection
  • CSRF protection
  • Post/Redirect/Get

For a normal POST form:

  1. Define the form class.
  2. Display an unbound form for GET.
  3. Bind request.POST for POST.
  4. Include request.FILES when uploading files.
  5. Call is_valid().
  6. Read validated values from cleaned_data.
  7. Perform the required action.
  8. Redirect after success.
  9. Render the same bound form when validation fails.

Conceptually:

text

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
GET
 ↓
Empty form
 ↓
Display

POST
 ↓
Bound form
 ↓
is_valid()
 ├── False → Display errors
 └── True
       ↓
    Process
       ↓
    Redirect

Mini Reference

text

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
forms.Form
    General-purpose Django form

forms.ModelForm
    Form connected to a Django model

is_valid()
    Runs form validation

cleaned_data
    Validated Python values

errors
    Validation errors

clean_<field>()
    Custom validation for one field

clean()
    Cross-field validation

required=False
    Makes a field optional

initial
    Starting field value

widget
    Controls HTML rendering

request.POST
    Submitted non-file form data

request.FILES
    Submitted uploaded files

instance
    Model instance being edited

save()
    Saves a ModelForm instance

save(commit=False)
    Creates instance without saving it yet

save_m2m()
    Saves deferred many-to-many relations

Basic form:

python

1
2
3
4
5
class ContactForm(forms.Form):
    name = forms.CharField(
        max_length=100,
    )
    email = forms.EmailField()

Basic processing:

python

1
2
3
4
5
6
7
if request.method == "POST":
    form = ContactForm(request.POST)

    if form.is_valid():
        data = form.cleaned_data
else:
    form = ContactForm()

Basic template:

django

1
2
3
4
5
6
7
8
<form method="post">
    {% csrf_token %}
    {{ form.as_p }}

    <button type="submit">
        Submit
    </button>
</form>

Basic model form:

python

1
2
3
4
5
6
7
class ArticleForm(forms.ModelForm):
    class Meta:
        model = Article
        fields = [
            "title",
            "content",
        ]

Django forms provide a structured way to receive, validate, clean, and process user input.

The main ideas are:

  • forms.Form is used for general input
  • forms.ModelForm connects forms to models
  • form fields define validation rules
  • widgets control HTML rendering
  • bound forms contain submitted data
  • is_valid() runs validation
  • cleaned_data contains validated Python values
  • field-specific validation uses clean_<field>()
  • cross-field validation uses clean()
  • model forms can create and update model instances
  • commit=False allows server-side fields to be set before saving
  • file forms require multipart/form-data and request.FILES
  • POST forms should use CSRF protection
  • authorization and data integrity should not rely only on forms

For a beginner Django application, start with a small Form or ModelForm, process it with the standard GET/POST pattern, and keep the validation rules close to the input they describe. Add custom widgets, formsets, dynamic fields, and more advanced form behavior only when the application requires them.

Join the Newsletter

Practical insights on Django, backend systems, deployment, architecture, and real-world development — delivered without noise.

Get updates when new guides, learning paths, cheat sheets, and field notes are published.

No spam. Unsubscribe anytime.



There is no third-party involved so don't worry - we won't share your details with anyone.