How AirForm Uses Pydantic for Form Validation: A Complete Guide

AirForm is a thin wrapper around Pydantic BaseModel that converts declarative data models into HTML form handlers with automatic validation, error handling, and rendering capabilities.

AirForm, part of the feldroy/air open-source repository, leverages Pydantic's robust type system to handle HTML form validation declaratively. By binding a Pydantic BaseModel to an AirForm class, developers can validate incoming request data, capture validation errors, and render HTML forms with error messages using minimal boilerplate code.

Binding Pydantic Models to AirForm Classes

At the core of AirForm's architecture is the AirForm class defined in src/air/forms.py. To create a form, you subclass AirForm and assign a Pydantic BaseModel to the class attribute model (line 65).

from pydantic import BaseModel
import air

class LoginModel(BaseModel):
    username: str
    password: str

class LoginForm(air.AirForm):
    model = LoginModel

This binding allows AirForm to delegate all type checking, coercion, and validation logic to Pydantic while maintaining a reference to the model schema for rendering HTML fields.

Validating Incoming Form Data with Pydantic

When a form is submitted, AirForm uses Pydantic to validate the raw form data against the bound model schema.

The validate() Method Implementation

The validate() method in src/air/forms.py (line 82) accepts either a dict or starlette.datastructures.FormData object. It attempts to instantiate the Pydantic model by calling self.model(**form_data).

If instantiation succeeds, self.is_valid is set to True and the populated model instance is stored in self.data. If Pydantic raises a ValidationError, AirForm catches the exception, stores the error list in self.errors, and sets self.is_valid to False.

Async Request Handling with from_request()

For FastAPI or Starlette applications, AirForm provides the from_request() classmethod (line 32 in src/air/forms.py). This async helper extracts the form payload using await request.form(), instantiates the form class, runs validation, and returns a ready-to-use form object.

@app.post("/login")
async def login(request: air.Request) -> air.Html:
    form = await LoginForm.from_request(request)
    if form.is_valid:
        return air.Html(air.H1(f"Welcome {form.data.username}!"))
    return air.Html(air.H1("Login failed"), air.Form(form.render()))

Rendering Forms with Pydantic Validation Errors

AirForm's render() method (line 84 in src/air/forms.py) generates HTML by inspecting the Pydantic model's model_fields attribute. It maps each field to an HTML input widget, using the field's type annotations and constraints to determine the appropriate input type.

When validation fails, render() accesses self.errors to inject aria-invalid attributes and display error messages next to the relevant fields. It also preserves user input by checking self.submitted_data (or initial_data), ensuring that form values persist after a failed validation attempt.

Creating Forms Programmatically with AirModel.to_form()

The AirModel class in src/air/models.py provides a convenience wrapper around BaseModel that exposes the to_form() method (line 22). This allows developers to generate an AirForm subclass dynamically without manually declaring a separate form class.

import air

class ContactModel(air.AirModel):
    name: str
    email: str = air.AirField(type="email")
    message: str = air.AirField(min_length=10)

# Generate form class dynamically

ContactForm = ContactModel.to_form()

This approach reduces boilerplate when the form fields map directly to the model schema without custom validation logic.

Practical Implementation Examples

Manual Form Validation

For scenarios requiring explicit form classes, bind your Pydantic model to an AirForm subclass:

from pydantic import BaseModel
import air

class RegistrationModel(BaseModel):
    username: str
    email: str
    age: int

class RegistrationForm(air.AirForm):
    model = RegistrationModel

@app.post("/register")
async def register(request: air.Request):
    form = await RegistrationForm.from_request(request)
    if form.is_valid:
        user_data = form.data  # RegistrationModel instance

        return air.Html(f"Registered {user_data.username}")
    return air.Html(form.render())

Pre-populated Form Data

Initialize forms with existing data for edit views:


# Existing user data from database

user_data = {"username": "alice", "email": "alice@example.com", "age": 30}

form = RegistrationForm(user_data)
html_output = form.render()  # Form inputs pre-filled with user_data

Custom Widget Rendering

Inject custom HTML generation logic for CSS framework integration:

def tailwind_widget(*, model, data=None, errors=None, includes=None):
    # Custom HTML generation with Tailwind classes

    return f"<div class='space-y-4'>{model.__name__} fields here</div>"

CustomForm = RegistrationModel.to_form(widget=tailwind_widget)

Summary

  • AirForm acts as a thin orchestration layer around Pydantic BaseModel, delegating all type validation and coercion to Pydantic's engine while adding HTML form handling capabilities.
  • Form classes declare their data schema by setting the model class attribute to a Pydantic model, as implemented in src/air/forms.py.
  • The validate() method instantiates the Pydantic model with submitted data, capturing ValidationError exceptions into a user-friendly errors dictionary accessible via self.errors.
  • The from_request() async classmethod streamlines FastAPI/Starlette integration by extracting form data and running validation in a single call.
  • Form rendering inspects model.model_fields to generate HTML inputs, automatically displaying validation errors and preserving submitted values through self.submitted_data.
  • The AirModel.to_form() shortcut in src/air/models.py generates form classes dynamically, eliminating boilerplate for simple use cases.

Frequently Asked Questions

How does AirForm handle Pydantic validation errors?

When the validate() method in src/air/forms.py calls the Pydantic model constructor, it wraps the call in a try-except block to catch Pydantic's ValidationError. If validation fails, AirForm extracts the error messages and stores them in the self.errors attribute while setting self.is_valid to False. This error dictionary maps field names to their respective error messages, which the render() method uses to display inline validation feedback next to the appropriate form fields.

Can I use AirForm with FastAPI's Request object?

Yes, AirForm provides the from_request() classmethod specifically for FastAPI and Starlette integration. Located at line 32 in src/air/forms.py, this async method accepts a Starlette Request object, extracts the form data using await request.form(), instantiates the form class, and runs validation automatically. This returns a fully populated form instance ready for checking is_valid or rendering error messages, eliminating the need to manually extract and pass form data.

What is the difference between AirForm and AirModel?

AirForm is the base form handler class defined in src/air/forms.py that requires manual binding to a Pydantic model via the model class attribute. AirModel, defined in src/air/models.py, is a thin wrapper around Pydantic's BaseModel that provides the to_form() convenience method. This method dynamically generates an AirForm subclass, eliminating the need to manually declare a separate form class when the form fields directly mirror the model schema.

How do I customize form rendering in AirForm?

AirForm supports custom widgets through the widget parameter in the to_form() method or by overriding the render() method in a subclass. The default widget inspects model.model_fields to generate HTML inputs, but you can inject a custom function that receives the model, data, errors, and includes parameters to generate arbitrary HTML. This allows integration with CSS frameworks like Tailwind or Bootstrap by generating the appropriate class attributes and HTML structure while still leveraging Pydantic's validation logic.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →