Home/Learn/FastAPI/Validation Deep Dive — Field, field_validator & model_validator

Validation Deep Dive — Field, field_validator & model_validator

Intermediate
Requests & Validation

Field() handles declarative constraints (bounds, lengths, patterns); @field_validator runs custom logic per field; @model_validator checks rules across fields — all producing structured 422s.

Overview

Pydantic v2 gives you three escalating validation tools. Field() covers the declarative 80%: numeric bounds (ge/gt/le/lt), string lengths and regex patterns, plus docs metadata. When a rule needs code — normalise a phone number, verify a pincode prefix, reject disposable email domains — @field_validator attaches a function to one field, and whatever it returns becomes the value (validators are also transformers). When a rule spans fields — end date after start date, at least one contact method present — @model_validator(mode="after") sees the whole model. Every failure joins the same 422 error list, so clients handle hand-written rules exactly like built-in ones. Keep validators pure: no DB calls, no I/O — that belongs in the service layer.

Field() Constraints + field_validator

Field() for declarative rules; @field_validator for logic. Raise ValueError with a human message — Pydantic wraps it into the standard error format. Returning a modified value normalises data at the boundary.

Declarative first; validators for logic — and they normalise too
from pydantic import BaseModel, Field, field_validator

class StudentIn(BaseModel):
    name: str = Field(min_length=2, max_length=60)
    phone: str                                    # custom rule below
    cgpa: float = Field(ge=0, le=10)
    pincode: str = Field(pattern=r"^[1-9][0-9]{5}$")   # Indian pincode shape

    @field_validator("phone")
    @classmethod
    def valid_indian_mobile(cls, v: str) -> str:
        digits = "".join(ch for ch in v if ch.isdigit())
        if digits.startswith("91") and len(digits) == 12:
            digits = digits[2:]                   # strip +91 → normalise
        if len(digits) != 10 or digits[0] not in "6789":
            raise ValueError("must be a valid 10-digit Indian mobile")
        return digits                             # ← transformed value is stored

    @field_validator("name")
    @classmethod
    def tidy_name(cls, v: str) -> str:
        return v.strip().title()                  # "  asha rao " → "Asha Rao"

# StudentIn(name="asha rao", phone="+91 98765 43210", cgpa=8.7, pincode="413001")
#   → phone stored as "9876543210", name as "Asha Rao"
# phone="12345" → 422: {"loc": ["body", "phone"],
#                       "msg": "Value error, must be a valid 10-digit Indian mobile"}

Cross-Field Rules with model_validator

mode="after" runs once all fields are individually valid, receiving the constructed model — the place for relationships between fields. mode="before" sees the raw dict, useful for reshaping legacy payloads.

after = business rules across fields; before = reshape raw input
from datetime import date
from pydantic import BaseModel, model_validator

class DriveWindow(BaseModel):
    company: str
    starts_on: date
    ends_on: date
    online: bool = False
    venue: str | None = None

    @model_validator(mode="after")
    def check_rules(self):
        if self.ends_on < self.starts_on:
            raise ValueError("ends_on must be on/after starts_on")
        if not self.online and not self.venue:
            raise ValueError("offline drives need a venue")
        return self                        # always return the model

# {"company":"Wipro","starts_on":"2026-08-01","ends_on":"2026-07-20"}
#   → 422 "ends_on must be on/after starts_on"
# {"company":"Wipro","starts_on":"2026-08-01","ends_on":"2026-08-02"}
#   → 422 "offline drives need a venue"   (online defaults to False)

# mode="before": reshape raw input before field validation —
class LegacyOrder(BaseModel):
    order_id: int
    amount: float

    @model_validator(mode="before")
    @classmethod
    def unwrap_envelope(cls, data):
        # old clients send {"data": {"order_id":..., "amount":...}}
        return data.get("data", data)

# Rules of thumb:
#   one field → field_validator · multiple fields → model_validator(after)
#   reshaping raw payloads → model_validator(before)
#   needs the database (uniqueness, existence) → NOT a validator; service layer

Key Points to Remember

  • 1Escalate: Field() constraints → field_validator → model_validator
  • 2Validators return the stored value — normalise (strip, title, +91) at the boundary
  • 3model_validator(mode="after") is the home for cross-field rules
  • 4Raise ValueError with a clear message; keep validators free of I/O and DB calls

Interview Questions

Sign in to ask Aria
1

Validate and normalise an Indian mobile number field — walk through your validator.

MediumJio
2

field_validator vs model_validator — when is each the right tool?

MediumOla
3

Why should "email must be unique" NOT be a Pydantic validator?

HardGoogle

Ask Aria about Validation Deep Dive — Field, field_validator & model_validator

Your personal AI tutor — ask anything about this concept

Revision Status

Personal Notes

Sign in to save personal notes for this topic.

Discussion

Sign in to join the discussion.

Loading discussion…