Skip to content

Live Validation (HTMX)

Live validation checks a field on the server while the user types or when they leave the field, and shows the result next to the form without a page reload. It uses HTMX: the field posts its value to a small endpoint and HTMX swaps the endpoint's answer into the page.

It's a convenience on top of normal validation, not a replacement: the submit route still calls validate(), because JavaScript can be turned off.

A complete example

Three files: the model and its live validator, a page template, and the routes.

models.py
from pydantic import EmailStr

from pydantic_schemaforms import Field, FormModel, LiveValidator


def live(field: str) -> dict[str, str]:
    """HTMX attributes that send the field to /validate/<field> as the user types."""
    return {
        'hx-post': f'/validate/{field}',
        'hx-trigger': 'blur, input delay:600ms',
        'hx-target': f'#{field}-feedback',
        'hx-swap': 'innerHTML',
    }


class ContactForm(FormModel):
    name: str = Field(title='Full name', min_length=2, ui_options=live('name'))
    email: EmailStr = Field(title='Email', ui_options=live('email'))


live_validator = LiveValidator.for_model(ContactForm, debounce_ms=600)
page.py
from models import live_validator

from pydantic_schemaforms import SafeHTML, html

VALIDATOR_SCRIPT = SafeHTML(live_validator.render_htmx_script())


def page(form_html: str) -> str:
    form = SafeHTML(form_html)  # trusted HTML from the library
    return html(t"""<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Contact</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
  <script src="https://unpkg.com/htmx.org@2.0.7"></script>
  {VALIDATOR_SCRIPT}
</head>
<body>
  <main class="container my-5">
    {form}
    <div id="name-feedback" aria-live="polite"></div>
    <div id="email-feedback" aria-live="polite"></div>
  </main>
</body>
</html>""")


def feedback(errors: list[str]) -> str:
    if not errors:
        return html(t'<span class="text-success">Looks good</span>')
    message = '; '.join(errors)  # may echo what the user typed: html() escapes it
    return html(t'<span class="text-danger">{message}</span>')
main.py
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from models import ContactForm, live_validator
from page import feedback, page

from pydantic_schemaforms import html, render_form_html_async, validation_response_headers

app = FastAPI()


@app.get('/contact', response_class=HTMLResponse)
async def show_contact():
    return page(await render_form_html_async(ContactForm, submit_url='/contact'))


@app.post('/contact', response_class=HTMLResponse)
async def submit_contact(request: Request):
    result = ContactForm.validate(await request.form(), submit_url='/contact')
    if not result.is_valid:
        return page(await result.render_with_errors_async())
    name = result.data['name']
    return html(t'<p>Thanks, {name}!</p>')


@app.post('/validate/{field_name}', response_class=HTMLResponse)
async def validate_field(field_name: str, request: Request):
    value = str((await request.form()).get(field_name, ''))
    result = live_validator.validate_field(field_name, value)
    return HTMLResponse(
        feedback(result.errors),
        headers=validation_response_headers(field_name, result.is_valid),
    )
main.py
from flask import Flask, request
from models import ContactForm, live_validator
from page import feedback, page

from pydantic_schemaforms import html, render_form_html, validation_response_headers

app = Flask(__name__)


@app.get('/contact')
def show_contact():
    return page(render_form_html(ContactForm, submit_url='/contact'))


@app.post('/contact')
def submit_contact():
    result = ContactForm.validate(request.form, submit_url='/contact')
    if not result.is_valid:
        return page(result.render_with_errors())
    name = result.data['name']
    return html(t'<p>Thanks, {name}!</p>')


@app.post('/validate/<field_name>')
def validate_field(field_name: str):
    value = request.form.get(field_name, '')
    result = live_validator.validate_field(field_name, value)
    return feedback(result.errors), validation_response_headers(field_name, result.is_valid)

Typing "A" in the name and pausing shows "String should have at least 2 characters"; finishing the name shows "Looks good".

How it works

  1. LiveValidator.for_model(ContactForm) derives a check for every field from the model itself: its constraints, its type (EmailStr) and its @field_validators. There's nothing to keep in sync.
  2. ui_options=live("name") adds HTMX attributes to the input: post to /validate/name on blur and 600 ms after the user stops typing, and put the answer into #name-feedback. Any ui_options key that isn't a known option is written onto the input as an attribute.
  3. One endpoint serves every field. validate_field(name, value) runs that field's check and returns a result with is_valid and errors. An unknown field name is reported as valid, so a stray request never breaks the page.
  4. validation_response_headers() tells the script from render_htmx_script() the outcome, so it marks the input is-valid or is-invalid and copies the message into the input's tooltip.

The feedback is built with html(t"...") because a message can include what the user typed: email-validator's "The domain name example.con does not exist", for example. The feedback elements use aria-live="polite" so screen readers announce the result.

Checks that aren't in the model

FieldValidator builds a check from rules, with your own messages. Register it after for_model() to replace that one field's check:

from pydantic_schemaforms import CustomRule, FieldValidator

username = FieldValidator("username")
username.min_length(3, message="At least 3 characters")
username.add_rule(CustomRule(
    lambda value: not value.lower().startswith("admin"),
    message="Usernames can't start with 'admin'",
))
live_validator.register_field_validator(username)

The available rules are in Validation Rules.

Email addresses that can receive mail

DeliverableEmailStr is a drop-in replacement for EmailStr that also looks up the domain's mail servers (MX records), so ada@example.con is rejected with a message saying why. It works in validate() and in live validation with nothing else to set up, and needs the email extra (pydantic-schemaforms[email]).

It's a real DNS lookup, so it always runs on the server and takes a moment: keep a debounce of at least 600 ms for it, or check it on blur only with live(...) returning "hx-trigger": "blur" for that field.

Triggers and debounce

for_model(..., debounce_ms=600) checks on blur and as the user types. For other combinations, pass an HTMXValidationConfig:

from pydantic_schemaforms import HTMXValidationConfig, LiveValidator

config = HTMXValidationConfig(
    validate_on_blur=True,
    validate_on_input=False,   # blur only
    validate_on_change=False,
    debounce_ms=400,
)
live_validator = LiveValidator.for_model(ContactForm, config=config)

The trigger written on the input (hx-trigger) is set by your ui_options, so keep the two in step.

Several forms, one endpoint

LiveValidator.for_model(ContactForm, SignupForm) covers several forms with one validator and one /validate/{field_name} route. Field names must be unique across those forms; for a repeated name, the last one registered wins.

Styling the result

Bootstrap styles is-valid and is-invalid on inputs out of the box. For Material or plain HTML, add your own rule:

input.is-invalid { border-color: #dc3545; }
input.is-valid   { border-color: #198754; }
Rendering a single field by hand

live_validator.render_field_with_live_validation("email", field_type="email", label="Email") returns one labelled input with its HTMX attributes and its own feedback element, for pages where you build the markup yourself instead of rendering a whole FormModel.

The example app's /live-validation and /email-dns-validation pages run these patterns live.