Recipes¶
Copy-paste patterns for common form tasks. Each recipe is self-contained — combine them freely.
Minimal form¶
The smallest possible form: define a FormModel, render it, handle submission.
from pydantic_schemaforms import Field, FormModel, render_form_html
class LoginForm(FormModel):
username: str = Field(..., ui_element="text", ui_placeholder="Username")
password: str = Field(..., ui_element="password", ui_placeholder="Password")
html = render_form_html(LoginForm, submit_url="/login")
For async (FastAPI):
from pydantic_schemaforms import render_form_html_async
html = await render_form_html_async(LoginForm, submit_url="/login")
GET + POST route (FastAPI)¶
Full round-trip: render on GET, validate on POST, re-render with errors if invalid.
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from pydantic_schemaforms import Field, FormModel, render_form_html_async
class ContactForm(FormModel):
name: str = Field(..., min_length=2, title="Full Name", ui_element="text")
email: str = Field(..., title="Email", ui_element="email")
message: str = Field(..., min_length=10, title="Message", ui_element="textarea")
app = FastAPI()
@app.api_route("/contact", methods=["GET", "POST"], response_class=HTMLResponse)
async def contact(request: Request):
if request.method == "POST":
data = dict(await request.form())
result = ContactForm.validate(data, submit_url="/contact")
if result.is_valid:
# process result.data …
return "<p>Thanks!</p>"
form_html = await result.render_with_errors_async()
else:
form_html = await render_form_html_async(ContactForm, submit_url="/contact")
return f"<html><body>{form_html}</body></html>"
GET + POST route (Flask)¶
from flask import Flask, request
from pydantic_schemaforms import Field, FormModel, render_form_html
app = Flask(__name__)
class ContactForm(FormModel):
name: str = Field(..., min_length=2, title="Full Name", ui_element="text")
email: str = Field(..., title="Email", ui_element="email")
message: str = Field(..., min_length=10, title="Message", ui_element="textarea")
@app.route("/contact", methods=["GET", "POST"])
def contact():
if request.method == "POST":
result = ContactForm.validate(request.form.to_dict(), submit_url="/contact")
if result.is_valid:
return "Thanks!"
form_html = result.render_with_errors()
else:
form_html = render_form_html(ContactForm, submit_url="/contact")
return f"<html><body>{form_html}</body></html>"
Jinja2 template embedding¶
Render form HTML server-side, pass it to the template, render with | safe.
# route
form_html = await render_form_html_async(ContactForm, submit_url="/contact")
return templates.TemplateResponse("page.html", {"request": request, "form_html": form_html})
<!-- page.html -->
<div class="container">
{{ form_html | safe }}
</div>
Field types¶
from typing import Optional
from pydantic_schemaforms import Field, FormModel
class AllFieldsExample(FormModel):
# Text inputs
name: str = Field(..., ui_element="text", ui_placeholder="Full name")
email: str = Field(..., ui_element="email", ui_placeholder="you@example.com")
password: str = Field(..., ui_element="password", ui_placeholder="••••••••")
phone: Optional[str] = Field(None, ui_element="tel", ui_placeholder="+1 555 000 0000")
website: Optional[str] = Field(None, ui_element="url", ui_placeholder="https://")
query: Optional[str] = Field(None, ui_element="search", ui_placeholder="Search…")
# Multi-line
bio: Optional[str] = Field(None, ui_element="textarea", ui_placeholder="Tell us about yourself")
# Numeric
age: Optional[int] = Field(None, ui_element="number", ge=13, le=120)
satisfaction: Optional[int] = Field(None, ui_element="range", ge=1, le=10)
# Date / time
birth_date: Optional[str] = Field(None, ui_element="date")
appt_time: Optional[str] = Field(None, ui_element="time")
# Colour picker
theme_color: str = Field("#3498db", ui_element="color")
# Select (explicit options list)
plan: str = Field("free", ui_element="select",
json_schema_extra={"ui_options": {"choices": [
{"value": "free", "label": "Free"},
{"value": "pro", "label": "Pro"},
{"value": "team", "label": "Team"},
]}})
# Boolean
newsletter: bool = Field(False, ui_element="checkbox", title="Subscribe to newsletter")
agree: bool = Field(False, ui_element="checkbox", title="I accept the terms")
Optional fields¶
Use Optional[T] with a default of None. Pydantic won't require these; the form renders them without a * marker.
from typing import Optional
from pydantic_schemaforms import Field, FormModel
class ProfileForm(FormModel):
display_name: str = Field(..., title="Display Name", ui_element="text")
bio: Optional[str] = Field(None, title="Bio", ui_element="textarea")
website: Optional[str] = Field(None, title="Website", ui_element="url")
Select from a Python Enum¶
Enum members become select options automatically. The field value is the enum's .value.
from enum import Enum
from pydantic_schemaforms import Field, FormModel
class Role(str, Enum):
USER = "user"
ADMIN = "admin"
MOD = "moderator"
class InviteForm(FormModel):
email: str = Field(..., title="Email", ui_element="email")
role: Role = Field(Role.USER, title="Role", ui_element="select",
json_schema_extra={"ui_options": {"choices": [
{"value": r.value, "label": r.value.capitalize()} for r in Role
]}})
Cross-field validation (password confirmation)¶
Use @field_validator with info.data to read already-validated sibling fields.
from pydantic import field_validator
from pydantic_schemaforms import Field, FormModel
class RegistrationForm(FormModel):
username: str = Field(..., min_length=3, title="Username", ui_element="text")
password: str = Field(..., min_length=8, title="Password", ui_element="password")
confirm_password: str = Field(..., title="Confirm Password", ui_element="password")
@field_validator("confirm_password")
@classmethod
def passwords_match(cls, v, info):
if "password" in info.data and v != info.data["password"]:
raise ValueError("Passwords do not match")
return v
Field-level @field_validator¶
Strip and sanitise a field value, or apply custom business rules.
from pydantic import field_validator
from pydantic_schemaforms import Field, FormModel
class CommentForm(FormModel):
username: str = Field(..., title="Username", ui_element="text")
body: str = Field(..., title="Comment", ui_element="textarea", min_length=5)
@field_validator("username")
@classmethod
def clean_username(cls, v: str) -> str:
v = v.strip()
if not v.isalnum():
raise ValueError("Username may only contain letters and numbers")
return v
Bootstrap vs Material Design¶
Pass framework to change the CSS framework. The default is "bootstrap".
from pydantic_schemaforms import render_form_html_async
# Bootstrap (default)
html = await render_form_html_async(MyForm, submit_url="/submit")
# Material Design 3
html = await render_form_html_async(MyForm, submit_url="/submit", framework="material")
Self-contained form (no CDN)¶
Inline all assets so the HTML chunk has zero external dependencies. Useful for email templates, offline apps, or sandboxed iframes.
html = await render_form_html_async(
MyForm,
submit_url="/submit",
self_contained=True, # inlines Bootstrap CSS/JS + Bootstrap Icons woff2
)
Repeating sub-forms (model list)¶
Let users add/remove items dynamically with a model_list field.
from typing import List
from pydantic_schemaforms import Field, FormModel
from pydantic_schemaforms.form_field import FormField
class PhoneEntry(FormModel):
label: str = Field(..., title="Label", ui_element="text", ui_placeholder="Mobile / Home / Work")
number: str = Field(..., title="Number", ui_element="tel", ui_placeholder="+1 555 000 0000")
class PersonForm(FormModel):
name: str = Field(..., title="Full Name", ui_element="text")
phones: List[PhoneEntry] = FormField(
default_factory=list,
title="Phone Numbers",
input_type="model_list",
model_class=PhoneEntry,
add_button_label="Add phone",
min_length=0,
max_length=5,
)
Parse the submitted flat form data back into the nested structure:
from pydantic_schemaforms import parse_nested_form_data
raw = dict(await request.form())
nested = parse_nested_form_data(raw)
result = PersonForm.validate(nested, submit_url="/person")
Customizing the list with Field()¶
The item model is resolved automatically from the list[ItemModel] type
annotation — model_class is only needed with the FormField constructor
shown above. Using the recommended Field() constructor, the same list
can be customized with these ui_* keyword arguments:
class PersonForm(FormModel):
name: str = Field(..., title="Full Name", ui_element="text")
phones: List[PhoneEntry] = Field(
default_factory=list,
title="Phone Numbers",
ui_element="model_list",
ui_add_button_label="Add phone",
ui_item_title_template="{label}: {number}",
ui_collapsible_items=False, # render flat cards instead of collapsible ones
ui_items_expanded=False, # start collapsed (only relevant if collapsible)
min_length=0,
max_length=5,
)
ui_item_title_templateis formatted against the item's own field values (here{label}and{number}fromPhoneEntry), plus{index}(1-based).- Item-count bounds (
min_length/max_lengthabove) are the same Pydantic constraint used for string length — they are not a separateui_optionssetting. - Nested per-item validation errors (keyed like
phones[0].numberbyFormModel.validate()) render inline on the matching item automatically.
Tabbed layout¶
Group fields into tabs. Users navigate between tabs without a page reload.
from pydantic_schemaforms import Field, FormModel
from pydantic_schemaforms.form_layouts import TabbedLayout
class ProfileForm(FormModel):
# Tab 1
first_name: str = Field(..., title="First Name", ui_element="text")
last_name: str = Field(..., title="Last Name", ui_element="text")
# Tab 2
bio: str = Field("", title="Bio", ui_element="textarea")
website: str = Field("", title="Website", ui_element="url")
class FormConfig:
layout = TabbedLayout(
("Personal", ["first_name", "last_name"]),
("Profile", ["bio", "website"]),
)
Horizontal layout¶
Render label and input side-by-side (Bootstrap grid).
from pydantic_schemaforms.form_layouts import HorizontalLayout
class SettingsForm(FormModel):
display_name: str = Field(..., title="Display name", ui_element="text")
timezone: str = Field("UTC", title="Timezone", ui_element="text")
class FormConfig:
layout = HorizontalLayout(label_cols=3, input_cols=9)
CSRF protection¶
Generate a token on GET, verify it on POST with a constant-time compare.
import hmac
import secrets
from fastapi import Request
from starlette.middleware.sessions import SessionMiddleware
from pydantic_schemaforms import CSRFMode, render_form_html_async
app.add_middleware(SessionMiddleware, secret_key="change-me-in-production")
SESSION_KEY = "csrf_token"
@app.get("/form", response_class=HTMLResponse)
async def get_form(request: Request):
token = secrets.token_urlsafe(32)
request.session[SESSION_KEY] = token
html = await render_form_html_async(
MyForm,
submit_url="/form",
csrf_mode=CSRFMode.REQUIRED_PROVIDER,
csrf_token_provider=token,
)
return f"<html><body>{html}</body></html>"
@app.post("/form", response_class=HTMLResponse)
async def post_form(request: Request):
data = dict(await request.form())
submitted = data.get("csrf_token", "")
expected = request.session.get(SESSION_KEY, "")
if not hmac.compare_digest(submitted, expected):
raise HTTPException(status_code=403, detail="Invalid CSRF token")
result = MyForm.validate(data, submit_url="/form")
# …
Dual-use: HTML form + JSON API from one model¶
as_api_model() strips ui_* keys so OpenAPI docs look hand-written, while all validation constraints are preserved.
from pydantic_schemaforms import Field, FormModel
class ContactForm(FormModel):
name: str = Field(..., min_length=2, title="Name", ui_element="text")
email: str = Field(..., title="Email", ui_element="email")
message: str = Field(..., min_length=10, title="Message", ui_element="textarea")
# Derive once at module level — safe to use as response_model or body type.
ContactSchema = ContactForm.as_api_model()
# HTML route
@app.api_route("/contact", methods=["GET", "POST"], response_class=HTMLResponse)
async def contact_html(request: Request): ...
# JSON API route — same validation, clean OpenAPI schema
@app.post("/api/contact", response_model=ContactSchema)
async def contact_api(data: ContactSchema):
return data
Live HTMX validation¶
Validate individual fields on blur without a page reload. Requires HTMX loaded in the page.
Server setup:
from pydantic_schemaforms import (
EmailRule, FieldValidator, HTMXValidationConfig, LiveValidator, MinLengthRule,
)
from pydantic_schemaforms.live_validation import validation_response_headers
from fastapi.responses import HTMLResponse
# Build once at module level.
validator = LiveValidator(HTMXValidationConfig(validate_on_blur=True))
fv_name = FieldValidator("name")
fv_name.add_rule(MinLengthRule(2, message="At least 2 characters"))
validator.register_field_validator(fv_name)
fv_email = FieldValidator("email")
fv_email.add_rule(EmailRule())
validator.register_field_validator(fv_email)
VALIDATOR_SCRIPT = validator.render_htmx_script()
@app.post("/validate/{field_name}", response_class=HTMLResponse)
async def validate_field(field_name: str, request: Request):
raw = await request.form()
value = str(raw.get(field_name, ""))
result = validator.validate_field(field_name, value)
if result.is_valid:
feedback = '<span class="text-success">Looks good!</span>'
else:
errors = "; ".join(result.errors)
feedback = f'<span class="text-danger">{errors}</span>'
return HTMLResponse(feedback, headers=validation_response_headers(field_name, result.is_valid))
Template (Jinja2 / plain HTML):
<!-- Load HTMX -->
<script src="/vendor/htmx.min.js"></script>
<!-- Inline the LiveValidator JS (applies is-valid / is-invalid classes) -->
{{ validator_script | safe }}
<!-- Field with live validation -->
<input
type="text" name="name" id="name"
hx-post="/validate/name"
hx-trigger="blur"
hx-target="#name-feedback"
hx-swap="innerHTML"
/>
<div id="name-feedback"></div>
Pass validator_script=VALIDATOR_SCRIPT in the template context.
Multiple triggers (blur + debounced input)¶
Enable several triggers together; they are combined into one hx-trigger attribute.
validator = LiveValidator(HTMXValidationConfig(
validate_on_blur=True,
validate_on_input=True,
validate_on_change=False,
debounce_ms=400, # wait 400 ms after typing stops
))
This generates hx-trigger="blur, input delay:400ms" on each field.
Validate a full Pydantic model field-by-field¶
Use register_model_validator when your validation rules live inside the Pydantic model itself rather than explicit FieldValidator rules. Each field is validated in isolation (other required fields are not needed).
from pydantic import BaseModel
from pydantic_schemaforms import HTMXValidationConfig, LiveValidator
class OrderForm(BaseModel):
quantity: int
sku: str
validator = LiveValidator(HTMXValidationConfig(validate_on_blur=True))
validator.register_model_validator(OrderForm)
# Now /validate/quantity and /validate/sku both work via validator.validate_field(...)
Pre-populate form with existing data¶
Pass a form_data dict to render the form with values already filled in (useful for edit pages).
existing = {"name": "Alice", "email": "alice@example.com", "message": "Hello"}
html = await render_form_html_async(
ContactForm,
submit_url="/contact",
form_data=existing,
)
Re-render with validation errors¶
After a failed POST, re-render showing field-level error messages.
@app.post("/contact", response_class=HTMLResponse)
async def post_contact(request: Request):
data = dict(await request.form())
result = ContactForm.validate(data, submit_url="/contact")
if result.is_valid:
return "<p>Sent!</p>"
# result stores the submit URL; no need to pass it again.
form_html = await result.render_with_errors_async()
return f"<html><body>{form_html}</body></html>"