Day 7 · ~13m

Writing Field Validators

Using @field_validator for custom validation logic with before and after modes.

🧑‍💻

Field constraints cover simple rules like min/max, but what about custom logic? Like "email must contain @" or "start date must be before end date"?

👩‍🏫

That's where @field_validator comes in. It lets you write arbitrary Python logic that runs during validation:

from pydantic import BaseModel, field_validator

class User(BaseModel):
    name: str
    email: str

    @field_validator("email")
    @classmethod
    def email_must_contain_at(cls, v):
        if "@" not in v:
            raise ValueError("must contain @")
        return v

The decorator @field_validator("email") says "run this method whenever the email field is being validated." The method receives the value v, checks it, and either returns the (possibly modified) value or raises ValueError to reject it.

🧑‍💻

Why does it need @classmethod? And what's the cls parameter?

👩‍🏫

Because the validator runs during model creation — before an instance exists. The cls parameter is the class itself (like User), not an instance. You won't use cls often, but Pydantic requires it for the decorator to work correctly:

class Product(BaseModel):
    name: str
    sku: str

    @field_validator("sku")
    @classmethod
    def sku_must_be_uppercase(cls, v):
        if v != v.upper():
            raise ValueError("SKU must be uppercase")
        return v
🧑‍💻

You said the validator can modify the value. Can I use it to normalize data, like stripping whitespace?

👩‍🏫

Yes — that's one of the most common uses. By default, @field_validator runs in after mode, meaning Pydantic has already done type coercion. You get a value that's already the right type, and you can transform it:

class Contact(BaseModel):
    name: str
    phone: str

    @field_validator("name")
    @classmethod
    def strip_and_title(cls, v):
        return v.strip().title()

    @field_validator("phone")
    @classmethod
    def normalize_phone(cls, v):
        digits = "".join(c for c in v if c.isdigit())
        if len(digits) != 10:
            raise ValueError("must be 10 digits")
        return digits

c = Contact(name="  alice smith  ", phone="(555) 123-4567")
print(c.name)   # "Alice Smith"
print(c.phone)  # "5551234567"
🧑‍💻

What's before mode? When would I use that instead?

👩‍🏫

In before mode, your validator runs before Pydantic does type coercion. Use it when the raw input needs transformation before it can even be parsed as the declared type:

class Settings(BaseModel):
    threshold: float

    @field_validator("threshold", mode="before")
    @classmethod
    def parse_percentage(cls, v):
        if isinstance(v, str) and v.endswith("%"):
            return float(v[:-1]) / 100
        return v

s = Settings(threshold="75%")
print(s.threshold)  # 0.75

Here, "75%" isn't a valid float, so after mode would fail. But before mode lets you intercept the raw string, strip the %, and convert it to 0.75 — then Pydantic sees a valid float.

🧑‍💻

Can I put multiple field names in one validator?

👩‍🏫

Yes. Pass multiple field names and the same validator runs for each:

@field_validator("first_name", "last_name")
@classmethod
def must_not_be_empty(cls, v):
    if not v.strip():
        raise ValueError("must not be empty")
    return v.strip()

This is great for applying the same normalization rule to multiple string fields without repeating yourself.

Practice your skills

Sign up to write and run code in this lesson.

Already have an account? Sign in