Pydantic Validators: field_validator and model_validator
The Field() rules from the previous lesson cover simple checks: length, pattern, range. But real projects have their own rules:
- A username must not be "admin".
- A name typed as "ravi KUMAR" should be saved as "Ravi Kumar".
- The "confirm password" must match the password.
- A leave's end date must not be before its start date.
For rules like these you write a validator: a method in your model that checks (and can also change) a value. This lesson follows 03_validators.py.
| Decorator | Runs for | Use it to |
|---|---|---|
@field_validator("name") | One field (or a few fields) | Check or clean one value |
@model_validator | The whole model | Compare two or more fields |
Both have two modes:
mode="after"(the default): runs after Pydantic has converted the type. You receive a clean value, e.g. a realint.mode="before": runs before type conversion, on the raw input. You can receive anything: text, a number, a list...
Inside a validator there are only two things to remember:
Return the value (you may change it) when it is fine.
Raise ValueError("message") when it is wrong. Pydantic turns it into a normal ValidationError.
1. field_validator: check and clean a value
from pydantic import BaseModel, ValidationError, field_validator
class Customer(BaseModel):
name: str
username: str
@field_validator("name")
@classmethod # validators are class methods
def clean_name(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("name cannot be blank")
return value.title() # "ravi KUMAR" -> "Ravi Kumar"
@field_validator("username")
@classmethod
def check_username(cls, value: str) -> str:
if " " in value:
raise ValueError("username must not contain spaces")
if value.lower() in ("admin", "root"):
raise ValueError(f"'{value}' is a reserved name")
return value.lower()
show_errors(Customer, name=" ravi KUMAR ", username="Ravi99")
show_errors(Customer, name=" ", username="the admin")
show_errors(Customer, name="Ravi", username="Admin")
How to read this code:
@field_validator("name")says "run this method for the fieldname".@classmethodmust come right under it. Validators run while the object is being created, so there is noselfyet, only the class (cls).valueis the incoming value. Because the mode is "after", it is already astr.clean_nameboth checks (blank is not allowed) and cleans (strip spaces, title case). Whatever you return is what gets stored:" ravi KUMAR "was saved as'Ravi Kumar'.- Your message appears with the prefix "Value error, ".
- The
show_errors()helper is the same as in lesson 02: it prints the model, or each error. This version prints(model)when an error belongs to the whole model.
2. One validator for several fields
If several fields need the same rule, list them all in one decorator:
from pydantic import ValidationInfo
class Address(BaseModel):
city: str
state: str
country: str = "India"
@field_validator("city", "state", "country")
@classmethod
def capitalise(cls, value: str, info: ValidationInfo) -> str:
# info.field_name tells you which field is being checked
if len(value.strip()) < 2:
raise ValueError(f"{info.field_name} is too short")
return value.strip().title()
show_errors(Address, city="pune", state="maharashtra")
show_errors(Address, city="p", state="mh")
The optional info parameter tells you which field is being checked (info.field_name), so the error message can name it.
Good to know: by default, validators don't run on default values, only on values that were actually passed in. Here country was not passed, so its default "India" was used without calling capitalise. (Add validate_default=True to the Field() if you ever need the default checked too.)
3. mode="before": fix the raw input before type checking
Users and other systems send messy data. A salary may arrive as "Rs. 1,50,000", which can't become an int. A "before" validator can clean it before Pydantic tries the conversion:
from typing import Any
class Salary(BaseModel):
amount: int
skills: list[str]
@field_validator("amount", mode="before")
@classmethod
def remove_commas(cls, value: Any) -> Any:
# The raw value can be anything: "1,50,000" or 150000
if isinstance(value, str):
return value.replace(",", "").replace("Rs.", "").strip()
return value # then Pydantic turns "150000" into int
@field_validator("skills", mode="before")
@classmethod
def split_text(cls, value: Any) -> Any:
# Accept "python, sql, excel" as well as ["python", "sql"]
if isinstance(value, str):
return [part.strip() for part in value.split(",") if part.strip()]
return value
print(Salary(amount="Rs. 1,50,000", skills="python, sql , excel"))
print(Salary(amount=90000, skills=["java"]))
- The type hint is
Any, because a "before" validator can receive anything. - Always check the type first (
isinstance(value, str)) and return other values unchanged. That's why90000and["java"]still work. - After the "before" validator, Pydantic does its normal job:
"150000"becomes the int150000.
4. model_validator(mode="after"): rules that use two fields
A field validator sees one field. To compare fields, use a model validator. In "after" mode it runs when every field is already valid, and you use self just like in a normal method:
from datetime import date
from typing import Self
from pydantic import Field, model_validator
class Registration(BaseModel):
email: str
password: str = Field(min_length=8)
confirm_password: str
@model_validator(mode="after")
def passwords_match(self) -> Self:
# Runs after every field is valid; use self.<field>
if self.password != self.confirm_password:
raise ValueError("passwords do not match")
return self # always return self
class LeaveRequest(BaseModel):
from_date: date
to_date: date
reason: str
@model_validator(mode="after")
def check_dates(self) -> Self:
if self.to_date < self.from_date:
raise ValueError("to_date must be on or after from_date")
if (self.to_date - self.from_date).days > 30:
raise ValueError("leave cannot be longer than 30 days")
return self
@property
def days(self) -> int:
return (self.to_date - self.from_date).days + 1
show_errors(Registration, email="a@b.com", password="secret123", confirm_password="secret123")
show_errors(Registration, email="a@b.com", password="secret123", confirm_password="secret999")
leave = LeaveRequest(from_date="2026-11-02", to_date="2026-11-06", reason="Family function")
print("Leave days:", leave.days)
show_errors(LeaveRequest, from_date="2026-11-06", to_date="2026-11-02", reason="Trip")
- An "after" model validator is a normal method with
self(no@classmethod). - It must return
self.Self(fromtyping, Python 3.11+) is the type hint for "an object of this class". - The error has no single field, so its location is empty; the helper prints it as
(model). - Because the dates were already converted to real
dateobjects, we can compare them and subtract them directly. daysis a normal Python@property: a calculated value, not a field. (In lesson 04 you'll see@computed_field, which also includes such values in the output.)
5. model_validator(mode="before"): reshape the raw input
A "before" model validator receives the whole raw input (usually a dict) before any field is checked. It is perfect for accepting data in a different shape. Here, a system sends full_name, but our model has first_name and last_name:
class Person(BaseModel):
first_name: str
last_name: str
@model_validator(mode="before")
@classmethod # "before" model validators are class methods
def split_full_name(cls, data: Any) -> Any:
# data is the raw input, usually a dict
if isinstance(data, dict) and "full_name" in data:
first, _, last = data.pop("full_name").partition(" ")
data.setdefault("first_name", first)
data.setdefault("last_name", last or "-")
return data
print(Person(first_name="Neha", last_name="Patil"))
print(Person.model_validate({"full_name": "Neha Patil"}))
print(Person.model_validate({"full_name": "Madonna"}))
- "before" model validators are class methods (there is no object yet), so they need
@classmethod. "Neha Patil".partition(" ")gives("Neha", " ", "Patil"); the middle part is ignored with_.- For a single name like "Madonna",
lastis empty, so"-"is used instead. - Normal input with
first_nameandlast_namestill works, because the validator returnsdataunchanged when there is nofull_name.
6. In which order do validators run?
This small model prints a line from every kind of validator, so you can see the order:
class Demo(BaseModel):
value: int
@model_validator(mode="before")
@classmethod
def step1(cls, data: Any) -> Any:
print(" 1. model before :", data)
return data
@field_validator("value", mode="before")
@classmethod
def step2(cls, value: Any) -> Any:
print(" 2. field before :", repr(value))
return value
@field_validator("value")
@classmethod
def step3(cls, value: int) -> int:
print(" 3. field after :", repr(value), "(now an int)")
return value
@model_validator(mode="after")
def step4(self) -> Self:
print(" 4. model after :", self)
return self
Demo(value="10")
raw input
│
▼
1. model_validator(mode="before") whole raw dict
│
▼
2. field_validator(mode="before") raw value of one field: '10' (text)
│
▼
Pydantic type conversion + Field() rules
│
▼
3. field_validator(mode="after") converted value: 10 (int)
│
▼
4. model_validator(mode="after") the complete object (self)
7. Reusable validators with Annotated and AfterValidator
Just like reusable Field() rules in lesson 02, you can turn a validator into a reusable type. Write a normal function, then attach it with AfterValidator:
from typing import Annotated
from pydantic import AfterValidator
def must_be_even(value: int) -> int:
if value % 2 != 0:
raise ValueError("must be an even number")
return value
def no_digits(value: str) -> str:
if any(ch.isdigit() for ch in value):
raise ValueError("must not contain digits")
return value
EvenNumber = Annotated[int, AfterValidator(must_be_even)]
CleanText = Annotated[str, AfterValidator(no_digits)]
class Team(BaseModel):
name: CleanText
members: EvenNumber
show_errors(Team, name="Tigers", members=6)
show_errors(Team, name="Team7", members=5)
The functions are plain Python: no decorators, no cls. That makes them easy to unit test on their own, and EvenNumber / CleanText can be used in any model. There is also BeforeValidator for the "before" mode.
Which validator should I use?
| You want to... | Use |
|---|---|
| Apply a simple length / range / pattern rule | Field() (lesson 02), no validator needed |
| Check or clean one field with your own logic | @field_validator (after) |
| Fix messy raw input before type conversion | @field_validator(mode="before") |
| Compare two or more fields | @model_validator(mode="after") |
| Accept input in a different shape | @model_validator(mode="before") |
| Reuse the same rule in many models | Annotated[type, AfterValidator(func)] |
Exercises
From the end of 03_validators.py:
- Model
Product(name, sku): afield_validatormakesskuUPPER CASE and raises an error if it does not start with "SKU-". - Model
PriceRange(min_price: float, max_price: float): amodel_validatorchecksmin_price <= max_price. - Model
Phone(number: str): amode="before"validator removes spaces, dashes and a leading "+91" so"+91 98765-43210"becomes"9876543210". - Model
Booking(adults: int, children: int): total people must be 1 to 6.
Summary
- Validators hold your own rules: return the (possibly changed) value, or raise
ValueError. @field_validatorworks on one or more fields and needs@classmethod.mode="before"sees the raw input;mode="after"(default) sees the converted value.@model_validator(mode="after")compares fields usingselfand must returnself.@model_validator(mode="before")can reshape the whole input dict.- Order: model before, field before, type conversion, field after, model after.
- Reusable rules:
Annotated[type, AfterValidator(function)].