# How AirForm Uses Pydantic for Form Validation: A Complete Guide

> Discover how AirForm leverages Pydantic for robust form validation. This guide explains AirForm's integration with Pydantic BaseModel for automatic validation, error handling, and rendering.

- Repository: [Feldroy/air](https://github.com/feldroy/air)
- Tags: how-to-guide
- Published: 2026-03-01

---

**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`](https://github.com/feldroy/air/blob/main/src/air/forms.py). To create a form, you subclass `AirForm` and assign a Pydantic `BaseModel` to the class attribute `model` (line 65).

```python
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`](https://github.com/feldroy/air/blob/main/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`](https://github.com/feldroy/air/blob/main/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.

```python
@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`](https://github.com/feldroy/air/blob/main/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`](https://github.com/feldroy/air/blob/main/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.

```python
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:

```python
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:

```python

# 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:

```python
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`](https://github.com/feldroy/air/blob/main/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`](https://github.com/feldroy/air/blob/main/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`](https://github.com/feldroy/air/blob/main/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`](https://github.com/feldroy/air/blob/main/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`](https://github.com/feldroy/air/blob/main/src/air/forms.py) that requires manual binding to a Pydantic model via the `model` class attribute. `AirModel`, defined in [`src/air/models.py`](https://github.com/feldroy/air/blob/main/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.