Skip to content

Forms

Starlette-Templates provides type-safe forms with automatic validation and rendering using Pydantic models. The FormModel base class extends Pydantic's BaseModel with form-specific functionality.

Creating forms

Define forms using the FormModel base class and field types:

from starlette_templates.forms import (
    FormModel, TextField, SelectField, SubmitButtonField
)

class ContactForm(FormModel):
    name: str = TextField(
        label="Your Name",
        placeholder="Enter your name",
        required=True,
        min_length=2,
    )

    category: str = SelectField(
        default="general",
        choices={
            "general": "General Inquiry",
            "support": "Technical Support",
            "sales": "Sales",
        },
        label="Category",
    )

    submit: str = SubmitButtonField(text="Send Message")

FormModel supports various field types (see Available Form Fields below) and validation using Pydantic's validators.

The fields, like TextField and SelectField, are Pydantic model fields with additional metadata for rendering and validation. These form fields define template components, labels, placeholders, and validation rules, in-addition to the standard Pydantic field behavior.

Using forms in routes

Basic usage

from starlette.routing import Route
from starlette.requests import Request
from starlette_templates.responses import TemplateResponse

async def contact_page(request: Request) -> TemplateResponse:
    # Parse form data and validate without raising on error
    form = await ContactForm.from_request(request, raise_on_error=False)

    # Check if form is valid and was submitted
    if form.is_valid(request):
        # Send email, save to database, etc.
        await send_contact_email(form.name, form.email, form.category)
        return TemplateResponse("success.html", context={"form": form})

    # Show form (with errors if validation failed)
    return TemplateResponse("contact.html", context={"form": form})

app = Starlette(
    routes=[Route("/contact", contact_page, methods=["GET", "POST"])]
)

Error handling

from pydantic import ValidationError

async def signup_page(request: Request) -> TemplateResponse:
    try:
        # Raise ValidationError on invalid data
        form = await SignupForm.from_request(request, raise_on_error=True)

        if form.is_valid(request):
            # Process valid form
            user = await create_user(form)
            return TemplateResponse("welcome.html", context={"user": user})

    except ValidationError as e:
        # Handle validation errors
        return TemplateResponse(
            "signup.html",
            context={"errors": e.errors()},
            status_code=400
        )

    # Show empty form for GET requests
    form = SignupForm()
    return TemplateResponse("signup.html", context={"form": form})

Rendering forms

Complete form

A FormModel instance can be rendered in templates using the form() function or by rendering individual fields.

Render the entire form with a single call:

<!DOCTYPE html>
<html>
<body>
    {{ form() }}
</body>
</html>

You can pass form attributes like method action, and enctype:

{{ form(
    method="POST", 
    action="/submit", 
    enctype="multipart/form-data")
}}

Individual fields

Render individual fields with full control:

<form method="POST" action="{{ request.url.path }}">
    {{ form.render('name') }}
    {{ form.render('email') }}
    {{ form.render('category') }}
    {{ form.render('subscribe') }}
    {{ form.render('submit') }}
</form>

Accessing field values

Access validated field values directly:

{% if form.is_valid(request) %}
<div class="alert alert-success">
    <p>Thank you, {{ form.name }}!</p>
    <p>We'll contact you at {{ form.email }}</p>
</div>
{% endif %}

Validation errors

Validation errors are automatically displayed when you render fields. The FormModel injects validation state and error messages into rendered components.

<form method="POST">
    <!-- Errors are automatically shown in the rendered field -->
    {{ form.render('email') }}
    {{ form.render('password') }}
    {{ form.render('submit') }}
</form>

Check if the form has validation errors:

{% if form.has_errors() %}
<div class="alert alert-danger">
    <p>{{ form.get_error_banner_text() }}</p>
</div>
{% endif %}

<form method="POST">
    {{ form.render('email') }}
    {{ form.render('password') }}
    {{ form.render('submit') }}
</form>

The has_errors() method returns True if there are any validation errors, and get_error_banner_text() returns the configured error banner message (default: "Please correct the errors below").

How error handling works

When FormModel.from_request() is called with raise_on_error=False:

  1. The form attempts to validate the request data
  2. If validation fails, errors are captured internally in _field_errors
  3. When you call form.render('field_name'), the error is automatically injected into the component
  4. The component displays the error message with appropriate styling (e.g., Bootstrap's is-invalid class)

This means you don't need to manually access or display errors - they're handled automatically by the rendering system.

Available form fields

TextField

TextField - Single-line text input:

name: str = TextField(
    label="Name",
    placeholder="Enter your name",
    required=True,
    min_length=2,
    max_length=100,
)

TextAreaField

TextAreaField - Multi-line text input:

message: str = TextAreaField(
    label="Message",
    placeholder="Enter your message",
    rows=5,
    required=True,
    min_length=10,
)

EmailField

EmailField - Email input with validation:

email: str = EmailField(
    label="Email",
    placeholder="[email protected]",
    required=True,
)

IntegerField

IntegerField - Numeric input for integers:

age: int = IntegerField(
    label="Age",
    min_value=18,
    max_value=120,
    required=True,
)

FloatField

FloatField - Numeric input for floats:

price: float = FloatField(
    label="Price",
    min_value=0.0,
    step=0.01,
    required=True,
)

CheckboxField

CheckboxField - Boolean checkbox:

agree: bool = CheckboxField(
    default=False,
    label="I agree to the terms",
    required=True,
)

SelectField

SelectField - Dropdown select (single or multiple):

# Single select
country: str = SelectField(
    label="Country",
    choices={
        "us": "United States",
        "uk": "United Kingdom",
        "ca": "Canada",
    },
    default="us",
)

# Multiple select
interests: list[str] = SelectField(
    label="Interests",
    choices={
        "tech": "Technology",
        "music": "Music",
        "sports": "Sports",
    },
    multiple=True,
)

DateField

DateField - Date picker with Flatpickr:

from datetime import date

birth_date: date = DateField(
    label="Birth Date",
    required=True,
)

HiddenField

HiddenField - Hidden input:

user_id: int = HiddenField(default=0)

SubmitButtonField

SubmitButtonField - Submit button:

submit: str = SubmitButtonField(text="Submit")

TagField

TagField - Tag input with comma separation:

tags: list[str] = TagField(
    label="Tags",
    placeholder="Enter tags separated by commas",
)

Form validation

Pydantic validators

Use Pydantic validators for custom validation logic:

from pydantic import field_validator, model_validator

class RegistrationForm(FormModel):
    username: str = TextField(label="Username", required=True)
    password: str = TextField(label="Password", type="password", required=True)
    confirm_password: str = TextField(label="Confirm Password", type="password", required=True)

    @field_validator('username')
    @classmethod
    def validate_username(cls, v):
        if len(v) < 3:
            raise ValueError('Username must be at least 3 characters')
        if not v.isalnum():
            raise ValueError('Username must be alphanumeric')
        return v

    @model_validator(mode='after')
    def validate_passwords(self):
        if self.password != self.confirm_password:
            raise ValueError('Passwords do not match')
        return self

Custom validation

Add custom validation methods:

class PaymentForm(FormModel):
    amount: float = FloatField(label="Amount", required=True)
    currency: str = SelectField(
        label="Currency",
        choices={"usd": "USD", "eur": "EUR", "gbp": "GBP"}
    )

    @field_validator('amount')
    @classmethod
    def validate_amount(cls, v):
        if v <= 0:
            raise ValueError('Amount must be positive')
        if v > 10000:
            raise ValueError('Amount cannot exceed 10,000')
        return v

Parsing request data

The model_from_request helper combines path parameters, query parameters, and body data:

from starlette_templates.forms import model_from_request
from pydantic import BaseModel

class UserData(BaseModel):
    name: str
    email: str
    age: int

async def create_user(request: Request):
    # Combines path params, query params, and body data
    # data is a validated UserData instance
    data = await model_from_request(request, UserData)

If path, query, and body parameters overlap, body data takes precedence over query parameters, which take precedence over path parameters.

If you are using a FormModel, use FormModel.from_request() instead to get form-specific behavior and validation.

from starlette_templates.forms import FormModel

class ProfileForm(FormModel):
    username: str
    bio: str

async def edit_profile(request: Request):
    # Combines path params, query params, and body data
    # form is a validated ProfileForm instance
    form = await ProfileForm.from_request(request)