You are working with AirForm, a Python library that turns Pydantic models into validated, rendered HTML forms. It reads presentation metadata from AirField and produces accessible HTML with zero configuration.
Most Air forms save to the database through AirModel. Some forms do something else (send an email, trigger an API call, run a search). AirForm handles both.
The typical case: the form fields match a database model. Use AirModel directly.
from airmodel import AirModel, AirField
from airform import AirForm
class BookOrder(AirModel):
id: int | None = AirField(default=None, primary_key=True)
title: str = AirField(label="Book Title", min_length=1)
quantity: int = AirField(label="Quantity", help_text="How many copies?")
gift_wrap: bool = AirField(default=False, label="Gift wrap")
class BookOrderForm(AirForm[BookOrder]):
pass
# Validate and save
form = BookOrderForm()
form.validate({"title": "Everyone Dies", "quantity": "3"})
if form.is_valid:
await BookOrder.create(**form.save_data())
# Render a blank form
html = BookOrderForm().render()The id field is skipped in the form because it has primary_key=True.
For forms that don't save to a database, use a plain Pydantic BaseModel. Contact forms, search forms, login forms, feedback forms.
from pydantic import BaseModel
from airfield import AirField
from airform import AirForm
class ContactMessage(BaseModel):
name: str = AirField(label="Your Name", autofocus=True)
email: str = AirField(type="email", label="Email")
message: str = AirField(widget="textarea", label="Message")
class ContactForm(AirForm[ContactMessage]):
pass
form = ContactForm()
form.validate({"name": "Audrey", "email": "audreyfeldroy@example.com", "message": "Hello!"})
if form.is_valid:
send_email(form.data.name, form.data.email, form.data.message)AirField wraps pydantic.Field and adds presentation metadata that AirForm reads when rendering:
| AirField parameter | What it does in the rendered HTML |
|---|---|
label="Display Name" |
<label> text (default: field name title-cased) |
type="email" |
<input type="email"> |
widget="textarea" |
<textarea> instead of <input> |
placeholder="hint..." |
placeholder attribute |
help_text="explanation" |
Help text div below the input |
choices=[("val","Label")] |
<select> with <option> elements |
autofocus=True |
autofocus attribute |
primary_key=True |
Field is skipped in form rendering |
min_length=N |
minlength="N" HTML5 attribute |
max_length=N |
maxlength="N" HTML5 attribute |
The typical web handler pattern with AirModel:
import air
from airmodel import AirModel, AirField
from airform import AirForm
app = air.Air()
class BookOrder(AirModel):
id: int | None = AirField(default=None, primary_key=True)
title: str = AirField(label="Book Title", min_length=1)
quantity: int = AirField(label="Quantity")
class BookOrderForm(AirForm[BookOrder]):
pass
@app.page
def order_page(request: air.Request) -> air.Html:
return air.Html(
air.H1("Order a Book"),
air.Form(
BookOrderForm().render(),
air.Button("Order", type_="submit"),
method="post", action="/order",
),
)
@app.post("/order")
async def submit_order(request: air.Request) -> air.Html:
form = await BookOrderForm.from_request(request)
if form.is_valid:
await BookOrder.create(**form.save_data())
return air.Html(air.H1(f"Ordered: {form.data.title}"))
return air.Html(
air.H1("Please fix the errors"),
air.Form(
form.render(), # re-render with errors + preserved values
air.Button("Order", type_="submit"),
method="post", action="/order",
),
)from_request() reads request.form(), validates with CSRF, and returns the populated form. Works with FastAPI's Depends for dependency injection.
form = MyForm()
form.validate({"name": "Audrey", "email": "audreyfeldroy@example.com"})After validate():
form.is_validisTrueorFalseform.datais the validated model instance (raisesAttributeErrorif not valid)form.errorsis a list of Pydantic error dicts, orNoneform.submitted_datais the raw dict that was submitted
Calling validate() again resets all state. No stale data leaks between calls.
html = form.render()render() produces HTML string with:
- A hidden CSRF token (automatic, signed with HMAC)
- One
<div class="air-field">per field containing label, input, and optional help text aria-invalid="true"and error messages on fields that failed validation- Checkboxes with the label after the input
- PrimaryKey and Hidden("form") fields skipped automatically
If validate() was called before render(), submitted values and errors are preserved in the re-rendered form.
CSRF is automatic. You don't configure it, you don't add fields to your model, you don't think about it.
render()embeds a signed token as a hidden inputvalidate()afterrender()pops the token and checks the HMAC before Pydantic runsvalidate()without a priorrender()skips CSRF (this is for programmatic use and tests)from_request()always enforces CSRF (browser submissions come from rendered forms)form.datanever has acsrf_tokenattribute; it's stripped before you see it
For multi-worker production, set AIRFORM_SECRET env var so all workers share the same signing key. Otherwise a per-process key is auto-generated.
When the form fields match the database model, use the AirModel directly:
class BookOrder(AirModel):
id: int | None = AirField(default=None, primary_key=True)
title: str = AirField(label="Title")
class BookOrderForm(AirForm[BookOrder]):
passWhen the form needs extra fields (confirm_password, terms checkbox) or different validation, define a separate form model with plain BaseModel:
from pydantic import BaseModel
class UserRegistration(BaseModel):
"""Form model, not the database model."""
username: str = AirField(label="Username", min_length=3)
email: str = AirField(type="email", label="Email")
password: str = AirField(type="password", label="Password", min_length=8)
confirm_password: str = AirField(type="password", label="Confirm Password")
class RegistrationForm(AirForm[UserRegistration]):
pass
class User(AirModel):
"""Database model, different fields."""
id: int | None = AirField(default=None, primary_key=True)
username: str
email: str
password_hash: strSwap the entire renderer by setting widget on your form class:
def my_renderer(*, model, data=None, errors=None, excludes=None):
# Build HTML however you want
return "<div>my custom form</div>"
class BookOrderForm(AirForm[BookOrder]):
widget = staticmethod(my_renderer)The widget receives the model class, pre-populated data, validation errors, and an optional excludes set. It returns an HTML string. The CSRF hidden input is added by render() outside the widget, so custom widgets get CSRF protection for free.
Hide fields from the form:
class ShippingForm(AirForm[Order]):
excludes = ("internal_notes", "created_at")Scoped excludes (display only, save only):
class ArticleForm(AirForm[Article]):
excludes = (
("slug", "display"), # not in form, still in save_data()
("internal_notes", "save"), # in form, excluded from save_data()
)Save to database:
if form.is_valid:
await MyModel.create(**form.save_data())Pre-populated edit form:
form = BookOrderForm({"title": "Existing Book", "quantity": 2})
html = form.render() # inputs have values filled inRe-render after validation errors:
form = BookOrderForm()
form.validate(submitted_data) # fails
html = form.render() # shows errors + preserves submitted values- Don't instantiate
AirFormdirectly. Always subclass it:class MyForm(AirForm[MyModel]): pass - Don't access
form.databefore callingvalidate()or after failed validation (it raisesAttributeError) - Don't put
csrf_tokenon your Pydantic model. AirForm handles it internally. - Don't import from
airform.formsorairform.csrfdirectly unless you're building a custom integration. Use the top-levelfrom airform import AirFormexports.
If a template has hand-written <input>, <textarea>, or <select> tags for a form, refactor it to use form.render(). The model already knows the field types, labels, and validation rules. Let AirForm generate the HTML, and you get CSRF, error re-rendering, and accessibility attributes for free.
The complete pattern: form.render() in the template, from_request() in the handler. If the form saves to a database, define the fields on an AirModel so the same model drives rendering, validation, and storage:
class BookOrder(AirModel):
id: int | None = AirField(default=None, primary_key=True)
title: str = AirField(label="Book Title", min_length=1)
quantity: int = AirField(label="Quantity")
class BookOrderForm(AirForm[BookOrder]):
pass
# Template: BookOrderForm().render()
# Handler: form = await BookOrderForm.from_request(request)
# Save: await BookOrder.create(**form.save_data())airfieldpackage defines presentation metadata types (Widget, Label, Choices, etc.) as frozen dataclasses onfield_info.metadataairmodelpackage provides the async ORM (AirModel extends BaseModel with database operations, re-exports AirField)airformreads AirField metadata and produces HTML. It's a consumer of AirField's vocabulary.- The renderer walks
model.model_fields, builds a{type: instance}dict of metadata per field for O(1) lookup, and produces the appropriate HTML element - CSRF is a pre-check in
validate(): pop the token from submitted data, verify the HMAC signature, fail fast if invalid. Then validate against the user's real model with all their validators. No wrapper model, no special Pydantic types in the validation path.