Unified Validation Engine Guide¶
Complete guide to validation in pydantic-schemaforms: server-side, real-time HTMX, and cross-field patterns.
Overview¶
The pydantic-schemaforms validation system is consolidated into a single, unified engine that works seamlessly across:
- Server-side validation via validate_form_data() and FormValidator
- Real-time HTMX validation via LiveValidator and field-level validators
- Cross-field validation via form-level rules
- Convenience validators for common patterns (email, password strength)
All validation rules live in pydantic_schemaforms/validation.py, re-exported from pydantic_schemaforms/live_validation.py for convenience, eliminating code duplication and ensuring consistency across all validation flows.
Core Concepts¶
ValidationResponse¶
The canonical response object for all validation operations (server-side or HTMX):
from pydantic_schemaforms import ValidationResponse
response = ValidationResponse(
field_name='email',
is_valid=True,
errors=[], # List of error messages
warnings=[], # List of warnings (non-blocking)
suggestions=['Example: user@example.com'], # Helpful hints
value='user@example.com', # The validated value
formatted_value='user@example.com', # Optionally formatted (e.g., lowercase)
)
# Serialize for HTMX responses
json_str = response.to_json()
dict_response = response.to_dict()
ValidationSchema & FieldValidator¶
Build reusable validation schemas from individual field validators:
from pydantic_schemaforms import EmailRule, FieldValidator, MinLengthRule, ValidationSchema
# Create a schema with multiple fields
schema = ValidationSchema()
# Add field validators
email_validator = FieldValidator('email')
email_validator.add_rule(EmailRule())
schema.add_field(email_validator)
password_validator = FieldValidator('password')
password_validator.add_rule(MinLengthRule(8, message='Minimum 8 characters required'))
schema.add_field(password_validator)
# Build HTMX live validator from schema
live_validator = schema.build_live_validator()
FormValidator¶
Validate entire forms with both field-level and cross-field rules:
from pydantic_schemaforms import EmailRule, FormValidator, NumericRangeRule
form_validator = FormValidator()
# Add field validators
form_validator.field('age').add_rule(NumericRangeRule(min_value=0, max_value=150))
form_validator.field('email').add_rule(EmailRule())
# Add cross-field validation
def validate_age_and_consent(data):
age = data.get('age')
consent = data.get('parental_consent')
if age is not None and age < 18 and not consent:
return False, {'parental_consent': 'Parental consent required for users under 18'}
return True, {}
form_validator.add_cross_field_rule(validate_age_and_consent)
# Validate form data
is_valid, errors = form_validator.validate(
{'age': 16, 'email': 'teen@example.com', 'parental_consent': False}
)
Server-Side Validation¶
Using FormModel.validate()¶
The recommended way to validate submitted data is FormModel.validate(). Pass submit_url
(and optionally framework plus any other render kwargs) once; on failure, call
render_with_errors() with no arguments — the result re-renders itself:
from pydantic_schemaforms import FormModel, FormField
class RegistrationForm(FormModel):
username: str = FormField(title='Username', min_length=3, max_length=20)
email: str = FormField(title='Email Address', input_type='email')
password: str = FormField(title='Password', input_type='password', min_length=8)
# Validate — stores submit_url so render_with_errors() needs no args
result = RegistrationForm.validate(
{'username': 'alice', 'email': 'alice@example.com', 'password': 'SecurePass123!'},
submit_url='/register',
)
if result.is_valid:
print(f'Valid! Data: {result.data}')
else:
print(f'Invalid! Errors: {result.errors}')
html = result.render_with_errors() # sync (Flask / scripts)
# html = await result.render_with_errors_async() # async (FastAPI)
result always exposes .is_valid, .data, and .errors regardless of the path.
.data mirrors the underlying Pydantic model's shape: sub-forms and lists
of sub-forms come back as nested dicts and lists. Pass the raw request form
(await request.form() / request.form) or a nested dict; the library decodes
the HTML input names itself.
.errors has the same shape as .data:
result.errors == {
'address': {'city': 'String should have at least 2 characters'},
'pets': {1: {'age': 'Must be 0 or greater'}}, # list items keyed by index
'_errors': 'Passwords do not match', # form-wide (@model_validator)
}
result.error_list() # [{"loc": ["address", "city"], "msg": "..."}, ...]
A node is either a message or a dict of child errors. A message about a whole
list or sub-form is a plain string, or sits under "_errors" when that
container also has child errors. Pass your own form-level message the same
way: errors={"_errors": "Session expired"}. docs/complex_forms.md has the
full rules.
See Submitted data and errors are nested JSON
in the recipes guide for a full nested-model example, and
JSON Schema output for
get_pydantic_json_schema(), which returns the standard, $defs-aware JSON
Schema of the form's underlying Pydantic model (as opposed to
get_json_schema()'s flattened UI shape).
Using validate_form_data() directly¶
validate_form_data() is the lower-level function used by FormModel.validate(). It is
still available for cases where you only need the validation result and do not intend to
re-render the form from the result object:
from pydantic_schemaforms import validate_form_data
result = validate_form_data(RegistrationForm, submitted_data)
if result.is_valid:
process(result.data)
Using FormValidator with Pydantic Models¶
To run rule-based checks on a submission that a FormModel also validates,
use result.original_data (the decoded submission, nested like the model)
and merge the two error sets; render_form_html() takes both shapes. The
example app's /validation page does exactly this:
result = ProfileForm.validate(await request.form(), submit_url='/profile')
rules_ok, rule_errors = rules.validate(result.original_data)
if not (result.is_valid and rules_ok):
return HTMLResponse(result.render_with_errors(errors={**rule_errors, **result.errors}))
Adding custom rules to a FormValidator¶
For validation with additional custom rules:
from pydantic_schemaforms import EmailRule, FormValidator, MaxLengthRule, MinLengthRule
form_validator = FormValidator()
form_validator.field('username').add_rule(MinLengthRule(3)).add_rule(MaxLengthRule(20))
form_validator.field('email').add_rule(EmailRule())
form_validator.field('password').add_rule(MinLengthRule(8))
# Validate and get results
is_valid, errors = form_validator.validate(
{'username': 'alice', 'email': 'alice@example.com', 'password': 'SecurePass123!'}
)
# Pydantic constraints are checked separately, through the model:
result = RegistrationForm.validate(request_data, submit_url='/register')
Real-Time HTMX Validation¶
LiveValidator Setup¶
Use LiveValidator for server-side validation triggered via HTMX on blur/input/change events:
from pydantic_schemaforms import (
LiveValidator,
HTMXValidationConfig,
FieldValidator,
EmailRule,
MinLengthRule,
)
# Configure HTMX behavior — any combination of triggers may be enabled.
# When multiple are True, the generated hx-trigger combines them:
# validate_on_blur=True + validate_on_change=True → hx-trigger="blur, change"
config = HTMXValidationConfig(
validate_on_blur=True, # Validate when field loses focus
validate_on_input=False, # Validate on every keystroke (with debounce)
validate_on_change=True, # Validate on value change (select, checkbox)
debounce_ms=300, # Debounce delay for validate_on_input
show_success_indicators=True, # Apply success_class on valid input
success_class='is-valid', # Bootstrap / custom CSS classes
error_class='is-invalid',
warning_class='has-warning',
loading_class='is-validating',
)
live_validator = LiveValidator(config)
# Register field validators
email_fv = FieldValidator('email')
email_fv.add_rule(EmailRule())
live_validator.register_field_validator(email_fv)
password_fv = FieldValidator('password')
password_fv.add_rule(MinLengthRule(8))
live_validator.register_field_validator(password_fv)
HTML Integration with HTMX¶
Use hx-swap="innerHTML" so HTMX replaces only the content of the feedback container,
keeping the element's id stable for future swaps:
<!-- Form field with HTMX validation -->
<input
type="email"
name="email"
id="email"
class="form-control"
placeholder="you@example.com"
hx-post="/validate/email"
hx-trigger="blur, change"
hx-target="#email-feedback"
hx-swap="innerHTML"
data-validate-endpoint="true"
/>
<!-- Validation feedback container -->
<div id="email-feedback"></div>
data-validate-endpoint="true"— the script emitted byrender_htmx_script()uses this attribute to attach loading-indicator and focus-clear behaviour automatically.
FastAPI Endpoint for HTMX Validation¶
A single parameterised endpoint handles all fields. validate_field() returns a
ValidationResponse; validation_response_headers() emits the HX-Trigger header that
the bundled JS uses to apply is-valid / is-invalid CSS classes to the input.
from fastapi import Request
from fastapi.responses import HTMLResponse
from pydantic_schemaforms import (
LiveValidator,
HTMXValidationConfig,
FieldValidator,
EmailRule,
MinLengthRule,
)
from pydantic_schemaforms import validation_response_headers
live_validator = LiveValidator(HTMXValidationConfig(validate_on_blur=True))
email_fv = FieldValidator('email')
email_fv.add_rule(EmailRule())
live_validator.register_field_validator(email_fv)
@app.post('/validate/{field_name}', response_class=HTMLResponse)
async def htmx_validate(field_name: str, request: Request):
data = await request.form()
value = str(data.get(field_name, ''))
result = live_validator.validate_field(field_name, value)
if result.is_valid:
feedback = '<span class="valid-feedback">✓ Looks good!</span>'
else:
feedback = f'<span class="invalid-feedback">{"; ".join(result.errors)}</span>'
headers = validation_response_headers(field_name, result.is_valid)
return HTMLResponse(feedback, headers=headers)
Include the init script in your template so the validationResult event updates CSS classes:
# Pass to template context
validator_script = live_validator.render_htmx_script()
<!-- In your base template, after HTMX is loaded -->
<script src="/vendor/htmx.min.js"></script>
{{ validator_script | safe }}
Building LiveValidator from ValidationSchema¶
from pydantic_schemaforms import FieldValidator, EmailRule
from pydantic_schemaforms import ValidationSchema
schema = ValidationSchema()
email_fv = FieldValidator('email')
email_fv.add_rule(EmailRule())
schema.add_field(email_fv)
# Converts the schema to a ready-to-use LiveValidator
live_validator = schema.build_live_validator()
Email DNS/MX Deliverability Checks¶
Pydantic's own EmailStr is a format check — user@thisdomaindoesnotexist.zzz passes
it. DeliverableEmailStr is a drop-in replacement that additionally performs a real DNS
lookup for the domain's MX records, catching typo'd or non-existent domains before you
send mail to them (useful, for example, when onboarding a client account and you want to
know the domain can actually receive mail). It requires the optional
email-validator package:
pip install pydantic-schemaforms[email]
# or: pip install email-validator
This isn't only a
DeliverableEmailStrthing. Pydantic's ownEmailStrrequiresemail-validatortoo — not just to run a check, but simply to define a model field with that type. AFormModelwith anemail: EmailStrfield raisesImportErrorthe moment the class body is evaluated ifemail-validatorisn't installed, regardless of whether you ever useDeliverableEmailStrorcheck_deliverability. SinceEmailStris this library's own recommended type for email fields (see the examples throughout this guide and the README), installpydantic-schemaforms[email]for any app with an email field, DNS checking or not.[email-dns]is kept as an alias for the same package, for anyone who found it via this section specifically.
Use it exactly like EmailStr — it's just a type, so there's no validator to construct,
register, or keep in sync with the field:
from pydantic_schemaforms import DeliverableEmailStr, Field, FormModel
class SignupForm(FormModel):
email: DeliverableEmailStr = Field(..., title='Email')
That one line is enough for FormModel.validate(), as_api_model()'s JSON endpoint, and
LiveValidator.for_model() (next section) to all enforce and live-check the DNS/MX rule
identically, since it's now part of the field's own type rather than a separate rule you
register by hand. Because it's a real network call, the check always runs server-side —
there's no way to do a DNS/MX lookup from browser JavaScript.
Unless you pass a fixed message, the error text is email-validator's own specific
reason for the failure rather than one generic string, so the user sees why the
address didn't pass — a typo'd/non-existent domain, a domain with no mail server, and a
reserved TLD all produce different text:
The domain name gmial.con does not exist.
The domain name example.com does not accept email.
The part after the @-sign is a special-use or reserved name that cannot be used with email.
Careful with
example.com/example.orgin your own tests — those RFC 2606 reserved placeholder domains (used everywhere else in this guide) have no MX record, soDeliverableEmailStrcorrectly rejects them. Use a real domain (likegmail.com) or a domain you control when trying this out.
See a runnable side-by-side comparison in the FastAPI example app at
GET /email-dns-validation (examples/fastapi_routes.py /
examples/templates/email_dns_validation.html) — two live email fields, one plain
EmailStr and one DeliverableEmailStr, so you can see the same address pass one and
fail the other.
Lower-level building blocks¶
DeliverableEmailStr is built on EmailDeliverabilityRule, which you can reach for
directly if you need a custom timeout, a fixed error message, or you're building a
field imperatively rather than through a typed model:
from pydantic_schemaforms import EmailDeliverabilityRule
EmailDeliverabilityRule(message="We couldn't verify that domain accepts mail.", timeout=5)
FieldValidator.email(check_deliverability=True), FormBuilder.email_input(check_deliverability=True),
and create_email_validator(check_deliverability=True) all expose the same rule for the
FieldValidator/FormBuilder/factory-function styles of building validation instead of
a FormModel type annotation:
from pydantic_schemaforms import FieldValidator
email_fv = FieldValidator('email').required().email(check_deliverability=True, dns_timeout=5)
If email-validator isn't installed, EmailDeliverabilityRule.validate() (and so
DeliverableEmailStr) raises ImportError with install instructions rather than
silently skipping the check or treating every address as valid.
Validating Pydantic Model Fields¶
LiveValidator.for_model() is the recommended way to wire up live validation for a whole
FormModel (or plain BaseModel) at once instead of building a FieldValidator per
field by hand — one call derives every field's live check straight from the model's own
Field() constraints and types (min_length, EmailStr, DeliverableEmailStr's DNS/MX
lookup, ...), checking as-you-type (debounced) instead of only on blur:
class ContactForm(FormModel):
name: str = Field(..., min_length=2)
email: DeliverableEmailStr = Field(...)
live_validator = LiveValidator.for_model(ContactForm, debounce_ms=600) # the whole setup
Pass more than one model to serve several forms' fields from a single validator/endpoint:
LiveValidator.for_model(ContactForm, SignupForm, debounce_ms=600). Pass config= instead
of debounce_ms= for anything beyond the debounce interval — e.g.
config=HTMXValidationConfig(validate_on_input=False) to go back to blur-only checking.
Under the hood, for_model() is LiveValidator(HTMXValidationConfig(validate_on_input=True,
debounce_ms=...)) plus a register_model_validator(YourModel) call per model — reach for
register_model_validator() directly if you're assembling a LiveValidator with a mix of
model-derived fields and hand-built FieldValidators. Each field is validated in isolation
using validate_assignment, so valid fields are never marked invalid just because other
required fields are absent:
from pydantic import BaseModel
from pydantic_schemaforms import LiveValidator
class ProfileModel(BaseModel):
name: str
age: int
live_validator = LiveValidator()
live_validator.register_model_validator(ProfileModel)
# Only the age field's own constraints are checked — name being absent is irrelevant
result = live_validator.validate_field('age', 'not-a-number')
# result.is_valid == False, result.errors == ["Input should be a valid integer..."]
result = live_validator.validate_field('age', 25)
# result.is_valid == True
Cross-Field Validation¶
Form-Level Rules¶
Validate fields that depend on other fields:
from pydantic_schemaforms import FormValidator, NumericRangeRule, RequiredRule
form_validator = FormValidator()
# Individual field rules
form_validator.field('age').add_rule(NumericRangeRule(min_value=0, max_value=150))
form_validator.field('parental_consent').add_rule(RequiredRule())
# Cross-field validation
def validate_minor_consent(data):
"""Minors must have parental consent."""
age = data.get('age')
consent = data.get('parental_consent')
if age is not None and age < 18 and not consent:
return False, {
'parental_consent': 'Parental consent is required for users under 18 years old'
}
return True, {}
form_validator.add_cross_field_rule(validate_minor_consent)
# Validate returns both field and cross-field errors
is_valid, errors = form_validator.validate({'age': 16, 'parental_consent': False})
# errors = {"parental_consent": ["Parental consent is required..."]}
Conditional Field Validation¶
Validate a field only if another field has a certain value:
def validate_emergency_contact(data):
"""Emergency contact required if no direct phone provided."""
has_phone = bool(data.get('phone'))
has_emergency_contact = bool(data.get('emergency_contact'))
if not has_phone and not has_emergency_contact:
return False, {
'emergency_contact': 'Either a phone number or emergency contact is required'
}
return True, {}
form_validator.add_cross_field_rule(validate_emergency_contact)
Password Matching Validation¶
def validate_passwords_match(data):
"""Ensure password and confirm_password match."""
password = data.get('password', '')
confirm = data.get('confirm_password', '')
if password and confirm and password != confirm:
return False, {'confirm_password': 'Passwords do not match'}
return True, {}
form_validator.add_cross_field_rule(validate_passwords_match)
Convenience Validators¶
Email Validator¶
from pydantic_schemaforms import create_email_validator
email_validator = create_email_validator()
response = email_validator('user@example.com')
# ValidationResponse(field_name="email", is_valid=True, ...)
response = email_validator('invalid-email')
# ValidationResponse(
# field_name="email",
# is_valid=False,
# errors=["Please enter a valid email address"],
# suggestions=["Example: user@example.com"],
# value="invalid-email"
# )
Password Strength Validator¶
from pydantic_schemaforms import create_password_strength_validator
password_validator = create_password_strength_validator(min_length=8)
response = password_validator('WeakPass')
# ValidationResponse(
# field_name="password",
# is_valid=False,
# errors=["Password must be at least 8 characters long"],
# warnings=[
# "Password should contain at least one uppercase letter",
# "Password should contain at least one number"
# ],
# suggestions=[
# "Add an uppercase letter (A-Z)",
# "Add a number (0-9)"
# ],
# value="WeakPass"
# )
response = password_validator('SecurePass123!')
# ValidationResponse(field_name="password", is_valid=True, ...)
Common Validation Rules¶
Built-in Rules¶
The validation system includes pre-built rules for common patterns:
| Rule | Purpose | Example |
|---|---|---|
RequiredRule() |
Field must have a value | Required name field |
MinLengthRule(n) |
Minimum string length | Username ≥ 3 chars |
MaxLengthRule(n) |
Maximum string length | Username ≤ 20 chars |
EmailRule() |
Valid email format | Email field |
PhoneRule() |
Valid phone number | Phone field |
NumericRangeRule(min_value, max_value) |
Numeric value range | Age 0–150 |
DateRangeRule(min_date, max_date) |
Date within range | Future date only |
RegexRule(pattern) |
Custom regex pattern | Custom format validation |
CustomRule(func) |
Custom validation function | Complex logic |
Example: Complete Field Validation¶
from pydantic_schemaforms import FieldValidator, EmailRule, MinLengthRule, MaxLengthRule
from pydantic_schemaforms import RequiredRule, NumericRangeRule, FormValidator
# Email field validator
email_validator = FieldValidator('email')
email_validator.add_rule(RequiredRule('Email is required'))
email_validator.add_rule(EmailRule())
# Username field validator (fluent API: .min_length() / .max_length())
username_validator = FieldValidator('username')
username_validator.add_rule(RequiredRule('Username is required'))
username_validator.add_rule(MinLengthRule(3, message='Minimum 3 characters'))
username_validator.add_rule(MaxLengthRule(20, message='Maximum 20 characters'))
# Age field validator
age_validator = FieldValidator('age')
age_validator.add_rule(NumericRangeRule(min_value=13, max_value=150, message='Must be 13+'))
# Use in form validator
form_validator = FormValidator()
form_validator.field('email').add_rule(EmailRule())
form_validator.field('username').add_rule(MinLengthRule(3)).add_rule(MaxLengthRule(20))
form_validator.field('age').add_rule(NumericRangeRule(min_value=13, max_value=150))
Sync + HTMX Validation Flow¶
End-to-End Example¶
Here's a complete registration form with both server validation and real-time HTMX feedback:
1. Define Form Model¶
from pydantic_schemaforms import FormModel, FormField
class RegistrationForm(FormModel):
username: str = FormField(
title='Username',
input_type='text',
min_length=3,
max_length=20,
help_text='3–20 alphanumeric characters',
)
email: str = FormField(
title='Email Address', input_type='email', help_text="We'll send a confirmation link"
)
password: str = FormField(
title='Password',
input_type='password',
min_length=8,
help_text='Must be at least 8 characters',
)
confirm_password: str = FormField(
title='Confirm Password', input_type='password', help_text='Re-enter your password'
)
age: int = FormField(
title='Age', input_type='number', ge=13, le=150, help_text='Must be 13 or older'
)
2. Set Up Validation¶
from pydantic_schemaforms import (
FieldValidator,
EmailRule,
MinLengthRule,
MaxLengthRule,
LiveValidator,
)
from pydantic_schemaforms import FormValidator, NumericRangeRule
# Create form validator with all rules
form_validator = FormValidator()
# Field validators
form_validator.field('username').add_rule(MinLengthRule(3, message='3–20 characters')).add_rule(
MaxLengthRule(20)
)
form_validator.field('email').add_rule(EmailRule())
form_validator.field('password').add_rule(MinLengthRule(8, message='Minimum 8 characters'))
form_validator.field('age').add_rule(
NumericRangeRule(min_value=13, max_value=150, message='Must be 13+')
)
# Cross-field rules
def validate_passwords_match(data):
if data.get('password') != data.get('confirm_password'):
return False, {'confirm_password': 'Passwords do not match'}
return True, {}
form_validator.add_cross_field_rule(validate_passwords_match)
# Live validator for HTMX -- FormValidator itself has no build_live_validator()
# (that's a ValidationSchema method); build one directly from the same
# FieldValidator instances FormValidator already holds.
from pydantic_schemaforms import LiveValidator
live_validator = LiveValidator()
for field_validator in form_validator.field_validators.values():
live_validator.register_field_validator(field_validator)
3. FastAPI Endpoints¶
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, JSONResponse
from pydantic_schemaforms import render_form_html_async
app = FastAPI()
@app.get('/register', response_class=HTMLResponse)
async def show_registration():
return await render_form_html_async(
RegistrationForm, framework='bootstrap', submit_url='/register'
)
@app.post('/register', response_class=HTMLResponse)
async def handle_registration(request: Request):
form_data = await request.form()
result = RegistrationForm.validate(
dict(form_data), submit_url='/register', framework='bootstrap'
)
if result.is_valid:
return JSONResponse({'success': True, 'message': 'Registration successful!'})
else:
return await result.render_with_errors_async()
# HTMX validation — one parameterised endpoint handles every field
@app.post('/validate/{field_name}', response_class=HTMLResponse)
async def htmx_validate(field_name: str, request: Request):
from pydantic_schemaforms import validation_response_headers
data = await request.form()
value = str(data.get(field_name, ''))
result = live_validator.validate_field(field_name, value)
if result.is_valid:
feedback = '<span class="valid-feedback">✓ Looks good!</span>'
else:
feedback = f'<span class="invalid-feedback">{"; ".join(result.errors)}</span>'
headers = validation_response_headers(field_name, result.is_valid)
return HTMLResponse(feedback, headers=headers)
4. HTML Template¶
<form hx-post="/register" hx-target="#form-result">
<!-- Username field with HTMX validation -->
<div class="form-group">
<label for="username">Username</label>
<input
type="text"
id="username"
name="username"
class="form-control"
placeholder="3–20 characters"
hx-post="/validate/username"
hx-trigger="blur, change delay:300ms"
hx-target="#username-feedback"
hx-swap="innerHTML"
/>
<div id="username-feedback"></div>
</div>
<!-- Email field with HTMX validation -->
<div class="form-group">
<label for="email">Email Address</label>
<input
type="email"
id="email"
name="email"
class="form-control"
placeholder="you@example.com"
hx-post="/validate/email"
hx-trigger="blur, change delay:300ms"
hx-target="#email-feedback"
hx-swap="innerHTML"
/>
<div id="email-feedback"></div>
</div>
<!-- Other fields... -->
<button type="submit" class="btn btn-primary">Register</button>
<div id="form-result"></div>
</form>
Testing Your Validators¶
The test suite includes comprehensive coverage. Use these patterns in your tests:
import pytest
from pydantic_schemaforms import (
FormValidator,
FieldValidator,
EmailRule,
ValidationResponse,
)
def test_email_validation():
email_validator = FieldValidator('email')
email_validator.add_rule(EmailRule())
# Valid email
response = email_validator.validate('user@example.com')
assert response.is_valid
assert response.errors == []
# Invalid email
response = email_validator.validate('not-an-email')
assert not response.is_valid
assert len(response.errors) > 0
def test_cross_field_validation():
form_validator = FormValidator()
def validate_passwords(data):
if data.get('password') != data.get('confirm'):
return False, {'confirm': "Passwords don't match"}
return True, {}
form_validator.add_cross_field_rule(validate_passwords)
is_valid, errors = form_validator.validate({'password': 'secret', 'confirm': 'different'})
assert not is_valid
assert 'confirm' in errors
The library's own tests¶
tests/test_validation.py covers the rule-based validators above;
tests/test_nested_model_forms.py covers validate() with sub-forms, the nested
error tree, and re-rendering errors inside the right tab, panel, or wizard step;
tests/test_layouts.py renders the example app's /layouts page under Bootstrap
and Material.
Run validation tests:
python -m pytest tests/test_validation.py -v
python -m pytest tests/test_nested_model_forms.py -v
python -m pytest tests/test_layouts.py -v
Summary¶
The unified validation engine provides:
- Canonical ValidationResponse for all validation flows
- Single code path via
validation.pywith re-exports fromlive_validation.py - Flexible rule composition via
FieldValidatorandFormValidator - HTMX integration via
LiveValidatorwith configurable behavior - Cross-field validation for dependent fields and complex rules
- Convenience validators for common patterns (email, password strength)
- Full async support for FastAPI and async frameworks
- Pydantic v2 compatibility with zero deprecation warnings in critical paths
For questions or examples, see:
- tests/test_validation.py — All validation tests
- tests/test_layouts.py — Layout/tab rendering verification including async
- examples/fastapi_routes.py — Real-world FastAPI integration