Skip to content

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.

DecoratorRuns forUse it to
@field_validator("name")One field (or a few fields)Check or clean one value
@model_validatorThe whole modelCompare 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 real int.
  • 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")
Output
OK -> name='Ravi Kumar' username='ravi99'
   ('name',): Value error, name cannot be blank
   ('username',): Value error, username must not contain spaces
   ('username',): Value error, 'Admin' is a reserved name

How to read this code:

  • @field_validator("name") says "run this method for the field name".
  • @classmethod must come right under it. Validators run while the object is being created, so there is no self yet, only the class (cls).
  • value is the incoming value. Because the mode is "after", it is already a str.
  • clean_name both 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")
Output
OK -> city='Pune' state='Maharashtra' country='India'
   ('city',): Value error, city is too short

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"]))
Output
amount=150000 skills=['python', 'sql', 'excel']
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 why 90000 and ["java"] still work.
  • After the "before" validator, Pydantic does its normal job: "150000" becomes the int 150000.

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")
Output
OK -> email='a@b.com' password='secret123' confirm_password='secret123'
   (model): Value error, passwords do not match
Leave days: 5
   (model): Value error, to_date must be on or after from_date
  • An "after" model validator is a normal method with self (no @classmethod).
  • It must return self. Self (from typing, 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 date objects, we can compare them and subtract them directly.
  • days is 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"}))
Output
first_name='Neha' last_name='Patil'
first_name='Neha' last_name='Patil'
first_name='Madonna' last_name='-'
  • "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", last is empty, so "-" is used instead.
  • Normal input with first_name and last_name still works, because the validator returns data unchanged when there is no full_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")
Output
   1. model  before : {'value': '10'}
   2. field  before : '10'
   3. field  after  : 10 (now an int)
   4. model  after  : 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)
Output
OK -> name='Tigers' members=6
   ('name',): Value error, must not contain digits
   ('members',): Value error, must be an even number

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 ruleField() (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 modelsAnnotated[type, AfterValidator(func)]

Exercises

From the end of 03_validators.py:

  1. Model Product(name, sku): a field_validator makes sku UPPER CASE and raises an error if it does not start with "SKU-".
  2. Model PriceRange(min_price: float, max_price: float): a model_validator checks min_price <= max_price.
  3. Model Phone(number: str): a mode="before" validator removes spaces, dashes and a leading "+91" so "+91 98765-43210" becomes "9876543210".
  4. 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_validator works 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 using self and must return self.
  • @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)].

Next: serialization: turning models into dicts and JSON.