Validation¶
Validation is Pydantic validation: the rules live on the model, and
validate() from Handling Submissions runs them.
There are three levels, and you use them in this order:
- Constraints on
Field()for a single value: length, range, pattern. @field_validatorfor a rule about one field that a constraint can't express.@model_validatorfor a rule about the form as a whole.
from datetime import date
from typing import Literal
from pydantic import ValidationInfo, field_validator, model_validator
from pydantic_schemaforms import Field, FormModel
class RegistrationForm(FormModel):
username: str = Field(title='Username', min_length=3, max_length=20, pattern=r'^[a-z0-9_]+$')
password: str = Field(title='Password', ui_element='password', min_length=8)
confirm_password: str = Field(title='Confirm password', ui_element='password')
age: int = Field(title='Age', ge=13, le=120)
start_date: date = Field(title='Start date')
end_date: date = Field(title='End date')
accept_terms: Literal[True] = Field(
title='I accept the terms',
unchecked_message='You must accept the terms to register',
)
@field_validator('username', mode='before')
@classmethod
def lowercase_username(cls, value: str) -> str:
return value.strip().lower()
@field_validator('confirm_password')
@classmethod
def passwords_match(cls, value: str, info: ValidationInfo) -> str:
if value != info.data.get('password'):
raise ValueError('Passwords do not match')
return value
@model_validator(mode='after')
def end_after_start(self) -> 'RegistrationForm':
if self.end_date < self.start_date:
raise ValueError('The end date must be after the start date')
return self
Use it with the routes from Handling Submissions.
Constraints¶
Constraints are the first choice: they're one keyword, they're always checked on submit, and most of them also become HTML attributes, so the browser catches many mistakes before the form is even sent.
| Constraint | Checks | Browser gets |
|---|---|---|
min_length=3, max_length=20 |
Length of a string or list | minlength, maxlength |
pattern=r"^[a-z0-9_]+$" |
A regular expression | pattern |
ge=13, le=120 |
At least / at most | min, max |
gt=0, lt=100 |
Strictly greater / less than | (checked on submit only) |
multiple_of=5 |
Divisible by | (checked on submit only) |
The messages are Pydantic's, with the range ones shortened: age=9 shows
"Must be 13 or greater".
Required checkboxes¶
To make the user tick a box, such as "I accept the terms", type it
Literal[True]. Ticking passes and leaving it unticked fails. An unticked box
submits nothing, so the error would be "Field required"; unchecked_message
replaces that with your own wording, as accept_terms does above. It also works
on a bool with no default.
Field validators¶
@field_validator is a classmethod that gets the value and returns it,
possibly changed, or raises ValueError with the message to show.
Cleaning up a value is a mode="before" validator, so it runs before the
constraints: lowercase_username turns " ADA " into "ada", which then
passes the lowercase-only pattern.
Comparing with another field uses info.data, which holds the fields
declared above this one that already passed. passwords_match puts its
error on the confirmation field, right where the user needs to fix it.
Model validators¶
@model_validator(mode="after") runs once every field has passed, with the
whole form as self. Use it for rules that aren't about any single field.
Its error is a form-wide error: it appears at the top of the form, in the
error summary, as "Form: The end date must be after the start date", and in
result.errors under the "_errors" key:
{"_errors": "The end date must be after the start date"}
A model validator only runs when every field is valid, so a user sees field errors first, then form-wide ones.
Field or form?
If the error has an obvious field to blame, use a field validator so the message sits next to that field. Use a model validator when it doesn't.
Where errors show up¶
After a failed submit, result.render_with_errors() shows:
- the user's values, still filled in;
- each field's message under the field, with the field marked invalid for
screen readers (
aria-invalid) and linked to the message; - a summary at the top listing every error, including form-wide ones.
result.errors holds the same messages, shaped like the form:
{"age": "Must be 13 or greater"}, and for sub-forms and lists, nested the
same way as the data; see Nested Forms and Lists.
Rule-based validation and live validation
Pydantic validation covers almost every form. The library also has a
rule-based layer (FormValidator, with ready-made rules such as
EmailRule and PhoneRule) for rules you want to share between the
submit route and live checks while the user types, or switch on and
off per route. See Rule-Based Validation
and Live Validation.
Next: Nested Forms and Lists.