# How to Handle and Display Form Validation Errors in Air

> Learn to handle form validation errors in Air. Air automatically validates Pydantic forms, stores errors efficiently, and renders accessible HTML with aria-invalid attributes for a better user experience.

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

---

**Air automatically validates Pydantic-backed forms via `AirForm.validate()`, stores errors in `form.errors`, and renders accessible HTML with `aria-invalid` attributes and user-friendly `<small>` messages when you call `form.render()`.**

The Air framework (feldroy/air) provides a tight integration between Pydantic models and HTML form rendering that makes handling and displaying form validation errors straightforward. By wrapping your data models in `AirForm` classes, you get automatic error detection, user-friendly message translation, and accessible markup injection without manual template logic.

## How AirForm Validates Incoming Data

The `AirForm` class in [`src/air/forms.py`](https://github.com/feldroy/air/blob/main/src/air/forms.py) acts as a thin wrapper around a Pydantic `BaseModel`. When you call `form.validate(data)`, it runs Pydantic validation and stores the results in two key attributes:

- **`form.errors`**: A list of Pydantic `ErrorDetails` dictionaries containing raw validation failures.
- **`form.is_valid`**: A boolean indicating whether the form passed validation.

According to the source code at lines 331–335 of [`src/air/forms.py`](https://github.com/feldroy/air/blob/main/src/air/forms.py), the `validate` method receives raw request data, runs `model.validate`, and populates these attributes. If validation fails, `form.errors` contains the specific field-level failures you need to display.

## Rendering Accessible Error Messages

Air’s default form renderer, `default_form_widget` (lines 540–606 in [`src/air/forms.py`](https://github.com/feldroy/air/blob/main/src/air/forms.py)), automatically translates raw Pydantic errors into accessible HTML. The renderer performs three critical tasks:

1. **Maps technical errors to friendly text** via `get_user_error_message` (lines 396–424), converting types like `"int_parsing"` into readable phrases such as "Please enter a valid number."
2. **Injects ARIA attributes** by adding `aria-invalid="true"` to any input field that failed validation, ensuring screen readers announce the error state.
3. **Displays inline messages** by inserting a `<small>` element (defined in [`src/air/tags/models/stock.py`](https://github.com/feldroy/air/blob/main/src/air/tags/models/stock.py) at line 2248) with the ID `{field_name}-error` containing the translated message.

The HTML generation logic looks like this:

```python
(tags.Small(get_user_error_message(error), id_=f"{field_name}-error") if error else "")

```

This produces markup like `<small id="email-error">This field is required.</small>` adjacent to the problematic input.

## Complete Implementation Examples

### Defining a Basic AirForm

Start by creating a Pydantic model and wrapping it in `AirForm`:

```python
import air
from pydantic import BaseModel

class ContactModel(BaseModel):
    name: str
    email: str | None = None
    message: str

class ContactForm(air.AirForm):
    model = ContactModel

```

*Source:* [`src/air/forms.py`](https://github.com/feldroy/air/blob/main/src/air/forms.py) (lines 95–106)

### Handling POST Requests with Error Display

In your view handler, validate the incoming data and re-render the form if errors exist:

```python
@app.post("/contact")
async def submit(request: air.Request) -> air.Html:
    form = ContactForm()
    data = await request.form()
    form.validate(data)                # ← populates form.errors & form.is_valid

    if form.is_valid:
        return air.Html(air.H1("Thank you!"))

    # Errors → render the same form; values are preserved automatically

    return air.Html(
        air.H1("Please fix the errors below."),
        air.Form(
            form.render(),               # ← includes <small> error messages

            air.Button("Send", type_="submit"),
            method="post",
            action="/contact",
        )
    )

```

*Key lines:* validation at [`src/air/forms.py`](https://github.com/feldroy/air/blob/main/src/air/forms.py) lines 331–335; rendering with errors at lines 361–371.

### Using Dependency Injection

For FastAPI-compatible dependency injection, use `from_request`:

```python
from fastapi import Depends

@app.post("/contact")
async def submit(contact: air.AirForm = Depends(ContactForm.from_request)):
    if contact.is_valid:
        return air.Html(air.H1(f"Hello {contact.data.name}!"))
    # Errors are already attached to the form instance

    return air.Html(
        air.H1("Fix the errors"),
        air.Form(contact.render(), air.Button("Resend"), method="post")
    )

```

*Source:* `ContactForm.from_request` implementation at [`src/air/forms.py`](https://github.com/feldroy/air/blob/main/src/air/forms.py) lines 133–158.

## Customizing Error Messages

To override the default user-friendly mappings, monkey-patch `get_user_error_message` before your forms render:

```python
from air.forms import get_user_error_message as _default_msg

def get_user_error_message(error: dict) -> str:
    # Use the built‑in mapping first

    msg = _default_msg(error)
    # Override a specific case

    if error.get("type") == "int_parsing":
        return "Numbers only, please."
    return msg

```

Place this customization in a module imported early in your application startup.

## Summary

- **AirForm** wraps Pydantic models to provide `validate()` and `is_valid` attributes for error detection according to [`src/air/forms.py`](https://github.com/feldroy/air/blob/main/src/air/forms.py) (lines 21–82).
- **Error rendering** is automatic via `form.render()`, which injects `aria-invalid="true"` and `<small>` elements with user-friendly text via `default_form_widget` (lines 540–606).
- **Error translation** happens through `get_user_error_message` in [`src/air/forms.py`](https://github.com/feldroy/air/blob/main/src/air/forms.py) (lines 396–424), mapping technical Pydantic error types to readable strings.
- **Test coverage** in [`tests/test_forms.py`](https://github.com/feldroy/air/blob/main/tests/test_forms.py) (lines 65–88) confirms that invalid forms produce HTML containing both accessibility attributes and correct error messages.
- **Customization** is possible by overriding the global `get_user_error_message` function for project-specific wording.

## Frequently Asked Questions

### How does Air map Pydantic error types to display messages?

Air uses the `get_user_error_message` function located at lines 396–424 in [`src/air/forms.py`](https://github.com/feldroy/air/blob/main/src/air/forms.py). This function inspects the `type` field of each Pydantic `ErrorDetails` dictionary (e.g., `"int_parsing"`, `"missing"`) and returns a corresponding human-readable string like "Please enter a valid number" or "This field is required."

### Can I customize which HTML tag is used for error messages?

The default `default_form_widget` (lines 540–606) hardcodes the use of the `Small` tag class from [`src/air/tags/models/stock.py`](https://github.com/feldroy/air/blob/main/src/air/tags/models/stock.py) (line 2248) for inline errors. To use a different tag, you would need to subclass `AirForm` and override the rendering logic or provide a custom widget function that replaces `default_form_widget` in your form configuration.

### Does Air preserve user input when redisplaying a form with errors?

Yes. When you call `form.render()` after validation fails, the renderer automatically injects the previously submitted values into each input field’s `value` attribute. This happens within the `default_form_widget` logic, ensuring users see their original input alongside the error messages.

### How can I test that my form errors render correctly?

The Air test suite demonstrates the expected HTML output in [`tests/test_forms.py`](https://github.com/feldroy/air/blob/main/tests/test_forms.py) (lines 65–88). You can verify error rendering by creating a form instance, calling `validate({})` with empty or invalid data, checking that `form.is_valid` is `False`, and asserting that `form.render()` produces strings containing `aria-invalid="true"` and your expected error messages within `<small>` tags.