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:
- The form attempts to validate the request data
- If validation fails, errors are captured internally in
_field_errors - When you call
form.render('field_name'), the error is automatically injected into the component - The component displays the error message with appropriate styling (e.g., Bootstrap's
is-invalidclass)
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)