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")
Code and structured-data fields (JSON, YAML, TOML, Bash, Python)¶
A textarea field can opt into a "Format" button, Tab-key indentation, and
light syntax highlighting for a specific language — still a plain
<textarea> under the hood (not an embedded code-editor component), and
fully functional with JavaScript disabled.
from pydantic_schemaforms import Field, FormModel
class ServiceConfigForm(FormModel):
name: str = Field(..., ui_element="text")
config: str = Field(
"{}",
title="Config (JSON)",
ui_element="textarea",
ui_options={"language": "json", "rows": 10},
)
language accepts "json", "yaml", "toml", "bash", or "python".
Three more ui_options toggle the pieces independently — all default True
and only take effect once language is set:
| Option | Effect when False |
|---|---|
code_format |
Hides the Format button |
code_highlight |
Hides the syntax-highlight overlay |
code_tab_indent |
Tab moves focus instead of inserting indentation |
indent_size (default 2) controls both the indent width used by the
formatter and the textarea's tab-size.
What "Format" actually does per language — this varies, and the button's label reflects it:
- JSON — real reserialization via the browser's native
JSON.parse/JSON.stringify, no extra download. - YAML / TOML — real parse-and-reserialize, using
js-yaml/smol-tomlrespectively (vendored, loaded only for fields using that language — seedocs/index.md's offline-assets notes for the general vendoring policy). - Bash / Python — whitespace-only cleanup (tabs → spaces, trailing
whitespace trimmed, extra blank lines collapsed). The button reads "Clean
up whitespace," not "Format" — there's no small, dependency-free
JavaScript equivalent to
black/shfmtfor real reformatting, so don't expect syntax-aware reindentation for these two.
Invalid input on Format (e.g. malformed JSON) never throws — the field gets
a code-invalid CSS class plus an inline error message, and the textarea's
content is left untouched until it's edited again.
# All five languages, with highlighting off for the two whitespace-only ones
class ScriptsForm(FormModel):
settings: str = Field("{}", ui_element="textarea", ui_options={"language": "json"})
manifest: str = Field("", ui_element="textarea", ui_options={"language": "yaml"})
pyproject: str = Field("", ui_element="textarea", ui_options={"language": "toml"})
setup_sh: str = Field(
"", ui_element="textarea", ui_options={"language": "bash", "code_highlight": False}
)
hook_py: str = Field(
"", ui_element="textarea", ui_options={"language": "python", "code_highlight": False}
)
Every input type¶
Every registered ui_element, generated from examples/input_type_reference.py (the same data backing the FastAPI demo's /input-types page). Run python scripts/generate_input_type_reference.py after changing that file to regenerate this section.
Text Inputs¶
text — Single-line free text. The default when no ui_element is given.
name: str = Field(..., ui_element='text', ui_placeholder='Full name')
textarea — Multi-line free text. Add language= for a Format button, Tab-key indentation, and syntax highlighting (json/yaml/toml/bash/python).
bio: str = Field('', ui_element='textarea', ui_options={'rows': 4})
config: str = Field('{}', ui_element='textarea',
ui_options={'language': 'json', 'rows': 8})
email — Email input with built-in browser validation and a numeric-friendly mobile keyboard.
email: str = Field(..., ui_element='email', ui_placeholder='you@example.com')
password — Password input; autocomplete defaults to 'new-password' so browsers don't offer to autofill a saved password.
password: str = Field(..., ui_element='password')
search — Search input with a search-oriented mobile keyboard.
query: str = Field('', ui_element='search', ui_placeholder='Search...')
url — URL input with browser validation and a default http(s):// pattern.
website: str = Field('', ui_element='url', ui_placeholder='https://example.com')
tel — Plain telephone input, no formatting applied.
phone: str = Field('', ui_element='tel')
phone (also accepts: phone_number) — Telephone input with a live-formatting mask as the user types.
phone: str = Field('', ui_element='phone',
ui_options={'phone_format': '(###) ###-####'})
ssn (also accepts: social_security_number) — Social Security Number input, formatted 123-45-6789 with a numeric keyboard.
ssn: str = Field('', ui_element='ssn')
credit_card (also accepts: card, cc_number) — Credit card number input, grouped 1234 5678 9012 3456.
card_number: str = Field('', ui_element='credit_card')
currency (also accepts: money) — Currency input with live thousands-separator formatting as the user types.
price: str = Field('', ui_element='currency',
ui_options={'currency_symbol': '$'})
Numeric Inputs¶
number — Plain HTML number input.
count: int = Field(0, ui_element='number')
integer — Number input constrained to whole numbers (step=1).
quantity: int = Field(1, ui_element='integer')
decimal — Number input with a configurable decimal precision (sets step from it).
price: float = Field(0.0, ui_element='decimal',
ui_options={'decimal_places': 2})
percentage — Number input clamped to 0-100 by default.
discount: float = Field(0, ui_element='percentage')
age — Integer input with sensible age bounds (0-150) pre-filled.
age: int = Field(..., ui_element='age')
quantity — Integer input for carts/inventory: min=1, placeholder '1'.
qty: int = Field(1, ui_element='quantity')
score — Number input with a configurable min/max range (default 0-100).
score: float = Field(0, ui_element='score',
ui_options={'min_score': 0, 'max_score': 10})
range — Native HTML range slider.
volume: int = Field(50, ui_element='range', ge=0, le=100)
slider — Range slider with min/max value labels shown alongside it.
opacity: int = Field(100, ui_element='slider', ge=0, le=100)
rating (also accepts: rating_stars) — Range slider styled to show a 1-N star rating (default max 5).
rating: int = Field(3, ui_element='rating', ui_options={'max_rating': 5})
temperature — Number input with a unit indicator (celsius/fahrenheit).
temp: float = Field(20.0, ui_element='temperature',
ui_options={'unit': 'celsius'})
Date/Time Inputs¶
date — Native HTML date picker (YYYY-MM-DD).
birth_date: date = Field(..., ui_element='date')
time — Native HTML time picker.
appt_time: time = Field(..., ui_element='time')
datetime (also accepts: datetime-local) — Native HTML datetime-local picker.
starts_at: datetime = Field(..., ui_element='datetime')
month — Native HTML month picker (YYYY-MM).
billing_month: str = Field(..., ui_element='month')
week — Native HTML week picker (YYYY-Www).
sprint_week: str = Field(..., ui_element='week')
birthdate — Date input pre-configured with sane birthdate bounds.
dob: date = Field(..., ui_element='birthdate')
Selection Inputs¶
checkbox — Single boolean checkbox.
agree: bool = Field(False, ui_element='checkbox', title='I accept the terms')
toggle (also accepts: toggle_switch, checkbox_toggle) — Single boolean rendered as a modern switch instead of a checkbox.
enabled: bool = Field(True, ui_element='toggle')
checkbox_group — Multiple booleans from one field, rendered as a group of checkboxes. ui_options collapsible=True turns the legend into a toggle that shows/hides the options -- purely presentational, does not affect required-ness.
channels: list[str] = Field(default_factory=list, ui_element='checkbox_group',
ui_options={'choices': ['email', 'sms', 'push'],
'collapsible': True})
radio — Single choice from a set of mutually-exclusive options. Also supports ui_options collapsible/collapsed, same as checkbox_group.
size: str = Field('medium', ui_element='radio',
ui_options={'choices': ['small', 'medium', 'large']})
select — Native HTML
multiselect — Multi-choice dropdown, enhanced with a searchable chips UI over the real
combobox — Text input with a suggestion dropdown (native
Files & Specialized Inputs¶
file — File upload input.
files: str = Field(..., ui_element='file',
ui_options={'accept': '.pdf,.docx', 'multiple': True})
color — Native HTML color picker.
theme_color: str = Field('#3498db', ui_element='color')
hidden — Hidden input, not shown to the user but submitted with the form.
session_id: str = Field(..., ui_element='hidden')
tags — Chip/tag entry: press Enter or comma to add a tag, Backspace to remove the last one; a hidden input carries the joined value.
keywords: str = Field('', ui_element='tags',
ui_help_text='Press Enter or comma to add a tag')
star_rating — Individually clickable stars (contrast with rating, which is a slider).
stars: int = Field(0, ui_element='star_rating', ui_options={'max_rating': 5})
captcha — Simple math-challenge CAPTCHA for spam resistance without a third-party service.
captcha: str = Field('', ui_element='captcha')
honeypot — Invisible trap field for bots -- real users never see or fill it; a filled honeypot means reject the submission.
website_url: str = Field('', ui_element='honeypot')
csrf — Hidden CSRF token field -- typically added by the framework integration, not hand-declared per form.
csrf_token: str = Field(..., ui_element='csrf')
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¶
A Literal[...]-typed field auto-populates its select/radio/checkbox_group/multiselect/combobox
options from the type itself, but a real enum.Enum/StrEnum field does not — Pydantic
points an Enum field's schema at a separate $defs entry via $ref rather than inlining its
values, and this library doesn't resolve $ref before building options. The failure mode isn't a
wrong label, it's a silently empty control with no visible error, so always build
ui_options={"choices": [...]} explicitly from the enum's members, as below. The field's
submitted/validated value is still 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.
CSV import to a table (DataTableLayout)¶
DataTableLayout is a FormModel whose fields describe table columns
instead of one instance's inputs: fields declared with
FormField(..., input_type=...) render as real, editable widgets per cell
(a dropdown for an Enum, etc.); plain fields render as read-only,
formatted text. The table is progressively enhanced client-side by a
vendored copy of DataTables.js (MIT-licensed, no
jQuery required) for search/sort/paging, plus optional csv/copy export
buttons. Since CSV import is what this class is for,
model_config["csv_upload"] defaults to True: the rendered table already
comes with a file input and "Load CSV"/"Submit" buttons built in — nobody
declares a separate file field or hand-builds that markup.
from enum import Enum
from pydantic_schemaforms.datatable_layout import DataTableLayout
from pydantic_schemaforms.schema_form import Field as FormField
class Color(str, Enum):
red = "red"
blue = "blue"
green = "green"
class ContactImport(DataTableLayout):
name: str
address: str
favorite_color: Color = FormField(Color.red, input_type="select")
model_config = {"buttons": ["csv", "copy"], "search": True, "csv_template": True}
It is not rendered on its own — it's embedded as one layout field on
a plain FormModel, exactly like TabbedLayout/any other layout (see
docs/plugin_hooks.md), and rendered through the same
render_form_html_async() call every other form in this library uses.
That embedding FormModel needs nothing but the one field:
from typing import Any
from pydantic_schemaforms import render_form_html_async
from pydantic_schemaforms.schema_form import Field, FormModel
class ContactsPage(FormModel):
rows: Any = Field(
default_factory=dict, title="Contacts",
input_type="layout", layout_handler="datatable",
)
html = await render_form_html_async(
ContactsPage,
form_data={"rows": ContactImport.as_layout_value(rows=current_rows, row_errors=row_errors)},
submit_url="/contacts/import",
include_submit_button=False, # ContactImport renders its own Submit -- see below
)
layout_handler="datatable" is registered automatically the moment you
import anything from pydantic_schemaforms.datatable_layout — no manual
registration step needed. The embedding FormModel's framework
(bootstrap/material/none), debug, and show_timing all apply to the
table automatically — there's nothing DataTableLayout-specific to configure
for those; they're just render_form_html_async()'s own parameters, same
as any other form.
Why include_submit_button=False?¶
Because csv_upload is on, ContactImport's own rendered output already
includes a "Submit" button right below the table — pass
include_submit_button=False so the embedding form doesn't also render
its own generic one, which would leave two redundant "Submit"-ish controls
on the page. This is safe without a nested <form>: a <button
type="submit">/<input type="file"> living inside this field's own slot
still submits/attaches to whatever <form> the embedding FormModel
opened — only a <form> nested inside another <form> is silently
dropped by the browser's HTML parser, and DataTableLayout never renders
one of its own (calling ContactImport.render_form() directly raises
NotImplementedError, inherited from CompositeLayoutModel — see
docs/plugin_hooks.md if you're building a similar composite of your own).
If you don't want the built-in upload UI at all (e.g. a read-only display
table, or your own custom upload flow), set "csv_upload": False in
model_config — the embedding form's own default submit button then
becomes "Submit" for the table's own editable cells, exactly like any other
layout field.
Load, review, then Submit — don't commit a CSV on one click¶
A single submit button that immediately saves whatever CSV you chose gives you no chance to catch and fix bad rows (a typo'd address, an invalid color) before they land in your database. So the built-in UI is two buttons: "Load CSV" parses the chosen file and re-renders the table with those rows for review — validation errors show up highlighted, nothing is saved yet — and "Submit" saves whatever the table is currently showing.
handle_import_post() on the model itself replaces hand-rolled
load-vs-submit branching: it reads which button was clicked, parses
accordingly (parse_csv_rows() for "Load", parse_submitted_rows() for
"Submit" — the same default a plain fallback submit uses, so nothing is
silently ignored), and returns an ImportResult with rows/row_errors
already merged back into their original order (merge_rows() — an invalid
row keeps its raw, as-typed values instead of vanishing):
form = await request.form()
result = await ContactImport.handle_import_post(form)
if result.action == "load":
# Preview only -- nothing committed. Re-render with reviewing=True so
# "Load CSV" relabels to "Reload CSV" and an info banner explains that.
return render_page(rows=result.rows, row_errors=result.row_errors, reviewing=True, notice=result.notice)
save_to_database(result.rows) # only "Submit" (result.action == "submit") ever gets here
if result.has_errors:
return render_page(rows=result.rows, row_errors=result.row_errors) # keep reviewing
return show_success_page(result.rows)
handle_import_post() reports what was actually submitted, with no opinion on whether an
empty result is meaningful — a "submit" with zero rows and no errors (e.g. Submit clicked
before any CSV was ever loaded) comes back looking like a valid, empty save. Decide for
yourself whether that's acceptable for your app; if not, check not result.rows and not
result.has_errors before calling save_to_database() and show a "nothing to submit" message
instead.
Every cell renders as its own named <input> (read-only columns get a
hidden one too, see below), so a submit's field count is rows times
columns, not row count. Starlette/FastAPI's Request.form() rejects a
request over its default max_fields=1000 with a 400 ("Too many fields") —
a plain 5-column table already exceeds that at 200 rows. Size a higher
max_fields to your largest expected import: await
request.form(max_fields=200_000).
No server-side "staging" storage is needed to connect Load and Submit: the rows a CSV load rendered for review are already sitting in the table's own form fields (both the editable cells and the read-only cells' hidden inputs — see below), so whatever the browser submits on the next click already reflects any hand-fixes made in between.
Every read-only column also carries forward a hidden input with its
current value, alongside its escaped display text — without that, a real
browser submit would only include genuinely-editable cells' own inputs,
and any read-only required field (like name above) would vanish from
the submission and fail that row's validation on every save.
as_layout_value()'s reviewing/notice/discard_url parameters only
affect the built-in upload UI: reviewing=True relabels the button and
shows the info banner described above; notice= shows a one-off warning
banner instead (handle_import_post() returns one in result.notice when
"Load" is clicked with no file chosen); discard_url= adds a "discard and
start over" link to the reviewing banner.
model_config keys¶
Recognized by DataTableLayout (any other key is ignored — pydantic does
not reject unknown model_config keys at runtime):
| Key | Default | Meaning |
|---|---|---|
buttons |
[] |
Export buttons: "csv", "copy". "pdf" is accepted but raises NotImplementedError at render time (pdfmake isn't vendored yet). |
search |
True |
Show the DataTables search box. |
paging |
True |
Enable client-side paging. |
page_length |
10 |
Default rows per page. Always included as a choice in the "entries per page" dropdown, even if not listed in length_menu. |
length_menu |
[10, 25, 50, 100] |
Choices offered in the "entries per page" dropdown. |
csv_template |
False |
Render a "Download CSV template" link above the table, as a data: URI built from ContactImport.csv_template_bytes() — no server route needed. |
csv_upload |
True |
Render the built-in file input + "Load CSV"/"Submit" buttons described above. Set False for a bare table with no upload UI (see above). |
editable |
True |
Whether cells declared with FormField(..., input_type=...) actually render as editable widgets. Set False to force every cell read-only regardless of its own input_type — see below. |
style |
see below | Table-appearance flags (dict) — see next section. |
editable: False — a bulk-replace-only table, no in-place edits¶
Set model_config["editable"] = False when the table should only ever be
replaced wholesale by re-uploading a CSV — every cell renders read-only no
matter what input_type a column declares, and the built-in UI drops
"Submit" entirely, leaving one "Reload CSV" button that both parses and
commits in a single click (handle_import_post() returns
action="reload") — there's no separate review-then-Submit step to wait on
when nothing in the table can be hand-corrected anyway:
class ReadOnlyImport(DataTableLayout):
name: str
favorite_color: Color = Field(Color.red, input_type="select") # ignored -- always read-only
model_config = {"editable": False}
result = await ReadOnlyImport.handle_import_post(await request.form())
# result.action is always "reload" here -- treat it the same as "submit":
save_to_database(result.rows)
Performance: every editable cell (a column declared with input_type=,
with editable left at its default True) renders as a real <select>/
<input> widget, with no virtualization — row count drives DOM size
directly. Past roughly 100 rows this gets noticeably slower to render,
paginate, and submit than plain read-only cells (escaped text plus a
lightweight hidden input for round-tripping). For imports past that size,
prefer a read-only presentation — either model_config["editable"] = False
if a bulk-replace-only flow fits (above), or declare the columns without
input_type if you still want the normal Load-then-Submit review step, just
without any cell being hand-editable.
Table appearance (style)¶
model_config["style"] maps to Bootstrap's own table-* utility classes.
It's a partial dict — any flag you omit keeps its default, so
{"striped": True} alone only changes striping and leaves the rest as-is:
| Style flag | Default | Bootstrap class added |
|---|---|---|
striped |
False |
table-striped |
bordered |
False |
table-bordered |
hover |
True |
table-hover |
compact |
False |
table-sm |
model_config = {"style": {"striped": True, "bordered": True, "compact": True}}
These are Bootstrap-specific classes: they have no visible effect under
framework="none" (a deliberately bare table) and there's no dedicated
Material variant yet — framework="material" currently gets the same
Bootstrap-ish classes as framework="bootstrap".
Performance: large imports¶
Note: imports over roughly 10,000 rows can feel slow. Rendering (not CSV parsing) dominates at that scale — every editable cell (any column declared with
FormField(..., input_type=...)) instantiates and renders its own input widget class, so cost scales withrows × editable columns, not just row count. Representative numbers fromtests/test_benchmarks.py(one editableselectcolumn out of three, plain Bootstrap styling, buttons enabled), measured server-side only:
Rows parse_csv_rowstable render 10 ~0.03 ms ~0.2 ms 100 ~0.3 ms ~1.9 ms 1,000 ~4 ms ~21 ms 10,000 ~45 ms ~220 ms That's server render time alone — a 10,000-row table is also several MB of HTML, and the browser then has to parse that markup, build 10,000+ DOM rows, and hand them to DataTables for client-side sort/search/paging, which is usually the slower part in practice. Run
pytest tests/test_benchmarks.py -k datatableyourself to reproduce these numbers on your own hardware/model shape.If you regularly expect imports in this range: keep as many columns read-only (plain fields, no
FormField(input_type=...)) as your UI can tolerate — read-only cells are a cheapstr()/escape, not a full widget render; and consider validating/importing in a background task instead of inline in the request/response cycle for the largest files.
Complete example: one field, one route¶
A trimmed copy of examples/datatable_import_example.py (which also adds
sample CSV files at 10/100/1,000/10,000 rows for trying the performance
behavior above yourself) — run python examples/main.py and open
/employees/import. Note how little of this is actually about the
CSV-import mechanics: no file field, no button HTML, no load-vs-submit
branching, no merge logic -- EmployeeImport and handle_import_post()
own all of that.
from enum import Enum
from typing import Any
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, Response
from pydantic import EmailStr
from pydantic_schemaforms import render_form_html_async
from pydantic_schemaforms.datatable_layout import DataTableLayout
from pydantic_schemaforms.schema_form import Field, FormModel
class Department(str, Enum):
engineering = "engineering"
sales = "sales"
support = "support"
class EmployeeImport(DataTableLayout):
name: str = Field(..., input_type="text")
email: EmailStr = Field(..., input_type="email")
department: Department = Field(Department.engineering, input_type="select")
model_config = {
"buttons": ["csv", "copy"],
"search": True,
"page_length": 25,
"csv_template": True,
"style": {"striped": True, "bordered": True},
}
class EmployeeImportForm(FormModel):
"""One field -- EmployeeImport's own rendered output already includes
the file input and Load/Submit buttons (csv_upload defaults to True)."""
employees: Any = Field(
default_factory=dict, title="Employees",
input_type="layout", layout_handler="datatable",
)
# Stand-in for a real database.
_ROWS: list[dict] = []
_ROW_ERRORS: dict[int, dict[str, str]] = {}
router = APIRouter(prefix="/employees")
async def _render_page(style: str, *, rows=None, row_errors=None, reviewing=False, notice="") -> str:
return await render_form_html_async(
EmployeeImportForm, framework=style,
form_data={"employees": EmployeeImport.as_layout_value(
rows=_ROWS if rows is None else rows,
row_errors=_ROW_ERRORS if row_errors is None else row_errors,
reviewing=reviewing, notice=notice, discard_url="/employees/import",
)},
submit_url="/employees/import", include_submit_button=False,
)
@router.get("/import", response_class=HTMLResponse)
async def show_import(style: str = "bootstrap"):
return await _render_page(style)
@router.post("/import", response_class=HTMLResponse)
async def import_or_save(request: Request, style: str = "bootstrap"):
global _ROWS, _ROW_ERRORS
result = await EmployeeImport.handle_import_post(await request.form())
if result.action == "load":
return await _render_page(
style, rows=result.rows, row_errors=result.row_errors,
reviewing=True, notice=result.notice,
)
_ROWS, _ROW_ERRORS = result.rows, result.row_errors
if result.has_errors:
return await _render_page(style) # keep reviewing until it's all valid
return show_success_page(result.rows) # your own success page
@router.get("/import/template.csv")
def download_template() -> Response:
return Response(
content=EmployeeImport.csv_template_bytes(), media_type="text/csv",
headers={"Content-Disposition": 'attachment; filename="employees_template.csv"'},
)
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
JSON Schema output¶
get_pydantic_json_schema() returns the standard JSON Schema of the form's
underlying Pydantic model — with real $defs/$ref for nested BaseModel
and List[BaseModel] fields. Use this instead of get_json_schema()
(a flattened, UI-oriented shape meant for the renderer, which does not
represent nested fields) whenever you need a spec-compliant schema for an
external consumer (OpenAPI tooling, a JSON Schema validator, a code
generator, etc.):
from typing import List
from pydantic_schemaforms import Field, FormModel
class Address(FormModel):
street: str = Field(..., title="Street")
city: str = Field(..., title="City")
class PersonForm(FormModel):
name: str = Field(..., title="Full Name", ui_element="text")
address: Address = Field(..., title="Address")
aliases: List[str] = Field(default_factory=list)
schema = PersonForm.get_pydantic_json_schema()
# schema["$defs"]["Address"] exists; schema["properties"]["address"] is a $ref
# Pass through Pydantic's own model_json_schema() options as needed:
openapi_schema = PersonForm.get_pydantic_json_schema(
ref_template="#/components/schemas/{model}"
)
get_pydantic_json_schema() is built on as_api_model() (same UI-key
stripping as the dual-use pattern above), so it is always additive and safe
to call regardless of what UI metadata the form declares.
Nested vs. flat form data¶
By default, FormModel.validate() returns result.data shaped exactly
like the underlying (possibly nested) Pydantic model — a List[BaseModel]
field like pets above comes back as a nested list of dicts, matching
model_dump(). Pass flatten=True to opt into bracket+dot flat keys
instead (the same notation used for model_list HTML field names and for
parse_nested_form_data()), for both the accepted input and the returned
result.data:
nested = {"owner": "Casey", "pets": [{"name": "Fido", "is_active": True}]}
# Default: nested in, nested out — mirrors the Pydantic model shape.
result = PersonForm.validate(nested, submit_url="/people")
# result.data == {"owner": "Casey", "pets": [{"name": "Fido", "is_active": True}]}
# flatten=True: flat in (nested input is also accepted), flat out.
result = PersonForm.validate(nested, submit_url="/people", flatten=True)
# result.data == {"owner": "Casey", "pets[0].name": "Fido", "pets[0].is_active": True}
flat_input = {"owner": "Casey", "pets[0].name": "Fido", "pets[0].is_active": True}
result = PersonForm.validate(flat_input, submit_url="/people", flatten=True)
# same result — flatten=True un-flattens bracket+dot input before validating
flatten_nested_data() (the inverse of parse_nested_form_data()) is
available directly if you need to flatten data outside of .validate():
from pydantic_schemaforms import flatten_nested_data
flatten_nested_data({"pets": [{"name": "Fido"}]})
# {"pets[0].name": "Fido"}
Custom date/time display formats¶
By default, date/time/datetime fields render as native HTML5 inputs
(<input type="date">, etc.) whose display is entirely controlled by the
browser's own locale — there's no way to force a custom display format on a
native control. Set date_format/time_format/datetime_format in
ui_options (standard strftime/strptime directives) to opt into a
custom shape instead — the field falls back to a live-formatted text input
so the display truly matches what you asked for:
from datetime import date, datetime, time
from pydantic_schemaforms import Field, FormModel
class ShiftForm(FormModel):
shift_date: date = Field(
..., title="Date", ui_element="date",
ui_options={"date_format": "%d/%m/%Y"}, # 05/03/2026
)
start_time: time = Field(
..., title="Start Time", ui_element="time",
ui_options={"time_format": "%H:%M"}, # 23:30 -- 24-hour ("military") time
)
logged_at: datetime = Field(
..., title="Logged At", ui_element="datetime",
ui_options={"datetime_format": "%Y%m%d%H%M%S"}, # year-month-day-hour(24)-minute-second
)
Without a *_format option, a field renders exactly as it always has. With
one set, the server still round-trips correctly either way: a submission in
the configured custom shape parses via that format first, and a plain ISO
string (e.g. from a JSON API client bypassing the HTML form entirely) still
validates too, since the custom-format parse attempt fails silently and
falls through to Pydantic's own ISO parser.
Switching to a text input doesn't lose the browser's native calendar/clock
picker UI — the field stays a single box with a small picker icon inside it
(live_format=True, the default) for any format a JS Date object can
supply a value for, which includes %Y %y %m %d %H %I %M %S as well as
%p (AM/PM) and %B %b %A %a (month/weekday names, via a hardcoded
English name table): clicking the icon opens the browser's own native
picker (via showPicker() on a hidden, non-submitting companion input) and
writes the selected value into the visible field, already converted to the
custom format. Only the rarer, non-Date-derivable directives (%Z, %j,
%f, week numbers, ...) omit the icon. Separately, live per-character
typing auto-punctuation only works for formats built entirely from
fixed-width numeric directives — %p/weekday/month-name formats still get
the picker icon, just not live-typing assistance.
This behavior is the same across every framework, including material — a
custom-formatted date/time/datetime field always uses this single-box
icon-toggle control, not a framework-specific widget.
For a much larger, live-runnable reference covering USA/Europe/Asia date
conventions, military (24-hour) time, and compact year-month-day-hour-
minute-second shapes — 21 formats across a 3-tab Date/Time/Datetime demo —
see examples/date_time_formats_example.py (DateTimeFormatsShowcaseForm),
wired up at the /date-time-formats route in examples/fastapi_routes.py.
Live Validation¶
"Live validation" means a field is checked server-side the moment the user blurs it or pauses typing — no page reload, no full form submit. It's built on three pieces that always work together the same way:
LiveValidatorholds a dict of{field_name: check_function}and aHTMXValidationConfig(which triggers fire, how long to debounce, which CSS classes to toggle).- HTMX attributes on the
<input>(hx-post,hx-trigger,hx-target) POST the field's current value to a small endpoint whenever a trigger fires, and swap the endpoint's HTML response into a feedback element — all without JavaScript you have to write. - A tiny endpoint (one per app, not one per field — see below) calls
validator.validate_field(name, value)and turns theValidationResponseinto an HTML fragment.
The end-to-end request cycle: user blurs the field (or pauses typing for
debounce_ms) → HTMX POSTs the value → the endpoint calls
validate_field() → the response HTML is swapped into #{field}-feedback
→ an HX-Trigger: validationResult header tells the bundled JS to add
is-valid/is-invalid to the input and mirror the feedback text into the
input's title attribute (a native tooltip — see "Showing why it failed"
below). The real POST submit route still calls FormModel.validate()
independently; live validation is UX sugar layered on top, not a
replacement — JavaScript can be disabled or bypassed.
Quickest start: LiveValidator.for_model()¶
For a FormModel (or plain BaseModel), one call derives a live check for
every field straight from the model's own Field() constraints and
types — nothing to duplicate, checking as-you-type (debounced) rather than
only on blur:
from pydantic_schemaforms import DeliverableEmailStr, Field, FormModel, LiveValidator
from pydantic_schemaforms.live_validation import validation_response_headers
from fastapi.responses import HTMLResponse
class ContactForm(FormModel):
name: str = Field(..., min_length=2, title="Full Name")
email: DeliverableEmailStr = Field(..., title="Email") # real DNS/MX check, see below
live_validator = LiveValidator.for_model(ContactForm, debounce_ms=600)
VALIDATOR_SCRIPT = live_validator.render_htmx_script() # embed once per page
@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 = live_validator.validate_field(field_name, value)
feedback = (
'<span class="text-success">✓ Looks good!</span>'
if result.is_valid
else f'<span class="text-danger">{result.errors[0]}</span>'
)
return HTMLResponse(feedback, headers=validation_response_headers(field_name, result.is_valid))
That single /validate/{field_name} route serves every field on the model —
validate_field() dispatches by name against whatever was registered, so
adding a field to the model is enough; there's no per-field route to add.
Passing an unregistered name returns is_valid=True with a warning rather
than a 404/500, so a stray field on the page never breaks the endpoint.
Rendering the field: two ways¶
A. render_field_with_live_validation() — builds a complete
<input> + <label> + feedback <div>, with the hx-trigger string
computed for you from the config (e.g. "blur, input delay:600ms"):
field_html = live_validator.render_field_with_live_validation(
"email", field_type="email", label="Email", value=""
)
<script src="/vendor/htmx.min.js"></script>
{{ validator_script | safe }} {# the string from render_htmx_script() #}
{{ field_html | safe }}
B. ui_options on a Field() — when the field is rendered through the
normal render_form_html_async(MyForm, framework=...) pipeline (so it gets
Bootstrap/Material/Plain HTML styling, debug panel, timing, etc. — see the
Bootstrap vs Material Design recipe),
attach the same HTMX attributes directly via ui_options; any key that
isn't a recognized layout option (choices, options, ...) passes through
as a literal HTML attribute on the rendered <input>, identically across
every framework:
class ContactForm(FormModel):
email: DeliverableEmailStr = Field(
..., title="Email", ui_element="email",
ui_options={
"hx-post": "/validate/email",
"hx-trigger": "blur, input delay:600ms",
"hx-target": "#email-feedback",
"hx-swap": "innerHTML",
"data-validate-endpoint": "true",
},
)
You then need one empty <div id="email-feedback"></div> somewhere on the
page for the response to swap into — it doesn't have to sit immediately
next to the input (see the tooltip note below).
Deriving checks from the model vs. hand-built rules¶
register_model_validator() (what for_model() calls internally) reads a
model's own Field() constraints — min_length, EmailStr,
DeliverableEmailStr's DNS/MX lookup, any @field_validator — so there's
one source of truth. Reach for FieldValidator + register_field_validator()
instead when the check isn't expressible as a model constraint, or you want
different wording than Pydantic's own error text:
from pydantic_schemaforms import CustomRule, FieldValidator
fv = FieldValidator("username")
fv.min_length(3, message="At least 3 characters")
fv.add_rule(CustomRule(
lambda v: not v.lower().startswith("admin"),
message="Usernames can't start with 'admin'",
))
live_validator.register_field_validator(fv) # overrides the model-derived check for this field only
Both live on the same LiveValidator instance without conflict — the last
register_* call for a given field name wins, so you can call
for_model(MyForm) first for the bulk of your fields and then override just
the one or two that need custom logic/messages.
Live DNS/MX email checks with DeliverableEmailStr¶
DeliverableEmailStr (see the Validation Guide
for the full writeup) is a drop-in EmailStr replacement that performs a
real DNS lookup for the domain's MX records — for_model() live-checks it
automatically, no extra registration:
class SignupForm(FormModel):
email: DeliverableEmailStr = Field(..., title="Email")
live_validator = LiveValidator.for_model(SignupForm, debounce_ms=600)
# /validate/email now rejects typo'd/non-existent domains, not just malformed syntax
Because it's a real network call, this check always runs server-side —
there is no way to do a DNS/MX lookup from browser JavaScript. The error
text is email-validator's own specific reason ("The domain name X does
not exist.", "...does not accept email.", a reserved-TLD message, ...),
so the user sees why, not one generic string. 600ms is a reasonable
default debounce for a field that triggers a network call per check; if
your DNS resolution is slow, either raise debounce_ms for the whole form:
live_validator = LiveValidator.for_model(SignupForm, debounce_ms=1500)
or drop that one field back to blur-only by overriding its rendered
hx-trigger directly — register_field_validator/for_model control which
checks run, not the HTML trigger, which lives on the field's own
ui_options (see "Rendering the field" above) or a
render_field_with_live_validation() call:
email: DeliverableEmailStr = Field(
..., ui_element="email", ui_options={"hx-trigger": "blur", ...},
)
Multiple forms, one validator¶
for_model() accepts more than one model class, so several forms on the
same app (or several fields of a comparison demo) can share a single
validator and a single /validate/{field_name} endpoint:
live_validator = LiveValidator.for_model(ContactForm, SignupForm, debounce_ms=600)
Field names must be unique across the combined set of models — the
validators dict is keyed by name only, so two different models both using a
field called email would collide (the later for_model/register_* call
wins).
Tuning triggers and debounce¶
HTMXValidationConfig's three boolean triggers combine into one
hx-trigger attribute; debounce_ms only applies to validate_on_input:
from pydantic_schemaforms import HTMXValidationConfig, LiveValidator
config = HTMXValidationConfig(
validate_on_blur=True, # -> "blur"
validate_on_input=True, # -> "input delay:400ms"
validate_on_change=False,
debounce_ms=400,
)
live_validator = LiveValidator.for_model(ContactForm, config=config)
# hx-trigger="blur, input delay:400ms"
Passing config= opts fully out of for_model()'s debounced-by-default
config rather than layering on top of it — use it to go back to blur-only
(HTMXValidationConfig(validate_on_input=False)) for expensive checks like
DNS lookups, or to disable a trigger entirely.
Showing why it failed¶
The bundled JS (render_htmx_script()) already does two things beyond
toggling is-valid/is-invalid on the input: it mirrors the swapped
feedback text into the input's title attribute, so hovering the field (or
its validity icon, where the framework draws one) shows a native tooltip
with the exact reason — useful when the visible feedback element isn't
immediately next to the input, or the page is narrow. Two things to be
aware of when styling the result yourself:
- Framework CSS scope:
is-valid/is-invalidare plain CSS classes the JS adds to the raw<input>; only Bootstrap's own CSS paints a border+icon for them out of the box, and only when the input also has Bootstrap's.form-controlclass (i.e. rendered withframework="bootstrap"). Under Material or Plain HTML rendering, add your own rule if you want a visual indicator:
input.is-invalid { border-color: #dc3545; }
input.is-valid { border-color: #198754; }
- Custom
message=: passing a fixedmessageto a rule (e.g.EmailDeliverabilityRule(message="...")) always shows that text instead of the specific reason — useful for consistent tone, at the cost of the detail.
Full runnable examples¶
Two complete, live demos ship in the example app:
GET /live-validation— a 3-field contact form,LiveValidator.for_model(ContactForm, debounce_ms=600).GET /email-dns-validation— two email fields side by side, oneEmailStrand oneDeliverableEmailStr, so you can watch the same address pass one and fail the other.
See examples/fastapi_routes.py (routes + model definitions) and
examples/templates/live_validation.html / email_dns_validation.html
(templates) for the full wiring, including the debug panel/timing/style-
switcher/submit handling every other demo in the app has.
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>"