Forms

HTML form handling, validation, and data parsing.

Overview

You can define a form by subclassing Form and declaring fields as class attributes. Each field handles parsing, validation, and type coercion for a specific input type.

from plain import forms
from plain.views import FormView


class ContactForm(forms.Form):
    email = forms.EmailField()
    message = forms.CharField()


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

    def form_valid(self, form):
        # form.cleaned_data contains validated data
        email = form.cleaned_data["email"]
        message = form.cleaned_data["message"]
        # Do something with the data...
        return super().form_valid(form)

When the form is submitted, you access validated data through form.cleaned_data. Each field converts the raw input to an appropriate Python type (strings, integers, dates, etc.).

Fields

All fields accept these common parameters:

  • required - Whether the field is required (default: True)
  • initial - Initial value for unbound forms
  • error_messages - Dict of custom error messages
  • validators - List of additional validator functions

Text fields

CharField accepts text input with optional length constraints.

name = forms.CharField(max_length=100, min_length=2)
bio = forms.CharField(required=False, strip=True)  # strip=True is the default

EmailField validates email addresses.

email = forms.EmailField()

URLField validates URLs and normalizes them (adds http:// if missing).

website = forms.URLField(required=False)

RegexField validates against a regular expression.

phone = forms.RegexField(regex=r"^\d{3}-\d{4}$")

Numeric fields

IntegerField parses integers with optional min/max/step validation.

age = forms.IntegerField(min_value=0, max_value=150)
quantity = forms.IntegerField(min_value=1, step_size=1)

FloatField parses floating-point numbers.

price = forms.FloatField(min_value=0)

DecimalField parses Decimal values with precision control.

amount = forms.DecimalField(max_digits=10, decimal_places=2)

Date and time fields

DateField parses dates in various formats (e.g., 2024-01-15, 01/15/2024).

birthday = forms.DateField()

TimeField parses times (e.g., 14:30, 14:30:59).

start_time = forms.TimeField()

DateTimeField parses combined date and time values.

scheduled_at = forms.DateTimeField()

DurationField parses time durations into timedelta objects.

duration = forms.DurationField()  # e.g., "1 day, 2:30:00"

Choice fields

ChoiceField validates against a list of choices.

PRIORITY_CHOICES = [
    ("low", "Low"),
    ("medium", "Medium"),
    ("high", "High"),
]
priority = forms.ChoiceField(choices=PRIORITY_CHOICES)

You can also use Python enums directly.

from enum import Enum

class Priority(Enum):
    LOW = "low"
    MEDIUM = "medium"
    HIGH = "high"

priority = forms.ChoiceField(choices=Priority)

TypedChoiceField coerces the value to a specific type after validation.

year = forms.TypedChoiceField(
    choices=[(str(y), str(y)) for y in range(2020, 2030)],
    coerce=int,
)

MultipleChoiceField allows selecting multiple options.

tags = forms.MultipleChoiceField(choices=[("a", "A"), ("b", "B"), ("c", "C")])

File fields

FileField handles file uploads.

document = forms.FileField(max_length=255)  # max_length applies to filename

ImageField validates that the upload is a valid image (requires Pillow).

avatar = forms.ImageField(required=False)

Other fields

BooleanField parses boolean values (handles HTML checkbox behavior).

subscribe = forms.BooleanField(required=False)  # unchecked = False
terms = forms.BooleanField()  # must be checked

NullBooleanField allows True, False, or None.

preference = forms.NullBooleanField()

UUIDField parses UUID strings into uuid.UUID objects.

token = forms.UUIDField()

JSONField parses and validates JSON strings.

config = forms.JSONField()
metadata = forms.JSONField(indent=2, sort_keys=True)  # for display formatting

Validation

Field-level validation

You can add custom validation for a specific field by defining a clean_<fieldname> method. This runs after the field's built-in validation.

class SignupForm(forms.Form):
    username = forms.CharField(max_length=30)
    email = forms.EmailField()

    def clean_username(self):
        username = self.cleaned_data["username"]
        if username.lower() in ["admin", "root", "system"]:
            raise forms.ValidationError("This username is reserved.")
        return username.lower()  # Return the cleaned value

Form-level validation

Override the clean() method for validation that involves multiple fields.

class PasswordForm(forms.Form):
    password = forms.CharField()
    password_confirm = forms.CharField()

    def clean(self):
        cleaned_data = super().clean()
        password = cleaned_data.get("password")
        confirm = cleaned_data.get("password_confirm")

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

        return cleaned_data

Errors raised in clean() are stored in form.non_field_errors since they are not associated with a specific field.

Custom error messages

You can customize error messages per field.

email = forms.EmailField(
    error_messages={
        "required": "We need your email address.",
        "invalid": "Please enter a valid email.",
    }
)

Rendering forms in templates

Forms provide access to field data through BoundField objects. You render the HTML inputs yourself, giving you full control over markup and styling.

<form method="post">
    <!-- Non-field errors (from form.clean()) -->
    {% for error in form.non_field_errors %}
    <div class="error">{{ error }}</div>
    {% endfor %}

    <div>
        <label for="{{ form.email.html_id }}">Email</label>
        <input
            type="email"
            name="{{ form.email.html_name }}"
            id="{{ form.email.html_id }}"
            value="{{ form.email.value }}"
            {% if form.email.field.required %}required{% endif %}>

        {% for error in form.email.errors %}
        <div class="field-error">{{ error }}</div>
        {% endfor %}
    </div>

    <div>
        <label for="{{ form.message.html_id }}">Message</label>
        <textarea
            name="{{ form.message.html_name }}"
            id="{{ form.message.html_id }}"
            {% if form.message.field.required %}required{% endif %}>{{ form.message.value }}</textarea>

        {% for error in form.message.errors %}
        <div class="field-error">{{ error }}</div>
        {% endfor %}
    </div>

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

Each bound field provides:

  • html_name - The input's name attribute
  • html_id - The input's id attribute
  • value - The current value (initial or submitted)
  • errors - List of validation error messages
  • field - The underlying Field instance
  • initial - The field's initial value

For large applications, you can reduce repetition by creating reusable patterns with Jinja includes, macros, or plain.elements.

JSON data

Forms automatically handle JSON request bodies when the Content-Type header is application/json. The same form class works for both HTML form submissions and JSON API requests.

class ApiForm(forms.Form):
    name = forms.CharField()
    count = forms.IntegerField()

For HTML form data:

POST /submit
Content-Type: application/x-www-form-urlencoded

name=Example&count=42

For JSON data:

POST /submit
Content-Type: application/json

{"name": "Example", "count": 42}

Both will validate the same way and populate cleaned_data with the same values.

FAQs

How do I make a field optional?

Set required=False on the field.

notes = forms.CharField(required=False)

How do I pre-populate a form with existing data?

Pass an initial dict when creating the form in your view.

form = ContactForm(request=request, initial={"email": user.email})

How do I access the raw submitted data?

Use form.data to access the raw data dict before validation.

if form.is_bound:
    raw_email = form.data.get("email")

How do I add custom validators to a field?

Pass a list of validator functions to the validators parameter.

from plain.validators import MinLengthValidator

username = forms.CharField(validators=[MinLengthValidator(3)])

Why is my checkbox field always False?

HTML checkboxes don't submit any value when unchecked. BooleanField handles this by returning False when the field is missing from form data. Make sure you use required=False if the checkbox is optional.

How do I handle multiple forms on one page?

Use the prefix parameter to namespace each form's fields.

contact_form = ContactForm(request=request, prefix="contact")
signup_form = SignupForm(request=request, prefix="signup")

This prefixes field names like contact-email and signup-email.

Installation

Add plain.forms to your INSTALLED_PACKAGES in app/settings.py.

INSTALLED_PACKAGES = [
    # ...
    "plain.forms",
]

Create a form class in your app.

# app/forms.py
from plain import forms


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

Use the form with a view. The FormView base class handles GET/POST logic automatically.

# app/views.py
from plain.views import FormView

from .forms import ContactForm


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

    def form_valid(self, form):
        # Process the validated data
        name = form.cleaned_data["name"]
        email = form.cleaned_data["email"]
        message = form.cleaned_data["message"]
        # Send email, save to database, etc.
        return super().form_valid(form)

Create the template to render the form.

<!-- app/templates/contact.html -->
{% extends "base.html" %}

{% block content %}
<h1>Contact Us</h1>

<form method="post">
    {% for error in form.non_field_errors %}
    <div class="error">{{ error }}</div>
    {% endfor %}

    <div>
        <label for="{{ form.name.html_id }}">Name</label>
        <input
            type="text"
            name="{{ form.name.html_name }}"
            id="{{ form.name.html_id }}"
            value="{{ form.name.value }}"
            required>
        {% for error in form.name.errors %}
        <div class="field-error">{{ error }}</div>
        {% endfor %}
    </div>

    <div>
        <label for="{{ form.email.html_id }}">Email</label>
        <input
            type="email"
            name="{{ form.email.html_name }}"
            id="{{ form.email.html_id }}"
            value="{{ form.email.value }}"
            required>
        {% for error in form.email.errors %}
        <div class="field-error">{{ error }}</div>
        {% endfor %}
    </div>

    <div>
        <label for="{{ form.message.html_id }}">Message</label>
        <textarea
            name="{{ form.message.html_name }}"
            id="{{ form.message.html_id }}"
            required>{{ form.message.value }}</textarea>
        {% for error in form.message.errors %}
        <div class="field-error">{{ error }}</div>
        {% endfor %}
    </div>

    <button type="submit">Send Message</button>
</form>
{% endblock %}