Skip to content

Validators Interview Questions & Answers

12 questions Updated 2026-06-20 Share:

FastAPI Pydantic validator interview questions — field_validator, model_validator, before/after mode, custom types and raising ValidationError.

12 of 12

Use @field_validator("field_name") decorator on a classmethod:

from pydantic import BaseModel, field_validator

class User(BaseModel):
    username: str
    age: int

    @field_validator("username")
    @classmethod
    def username_must_be_alphanumeric(cls, v: str) -> str:
        if not v.isalnum():
            raise ValueError("username must be alphanumeric")
        return v.lower()   # transform the value

    @field_validator("age")
    @classmethod
    def age_must_be_positive(cls, v: int) -> int:
        if v < 0:
            raise ValueError("age must be non-negative")
        return v

ValueError messages are caught by Pydantic and included in the ValidationError detail. You can also raise PydanticCustomError for more structured errors.

Rule of thumb: field validators run per-field; use them for constraints that can't be expressed with Field() parameters (cross-value logic belongs in model validators).

mode controls when the validator runs relative to Pydantic's built-in type coercion:

Mode Runs Input type Use case
"before" (default v2) before coercion raw input (str/dict/etc.) normalize raw strings
"after" after coercion declared Python type validate typed value
"wrap" wraps coercion raw + handler callable conditional coercion
"plain" replaces coercion raw input fully custom type parsing
from pydantic import BaseModel, field_validator

class Order(BaseModel):
    amount: float

    @field_validator("amount", mode="before")
    @classmethod
    def strip_currency_symbol(cls, v):
        if isinstance(v, str):
            return v.lstrip("$£€")   # "£9.99" → "9.99"
        return v

Rule of thumb: use mode="before" to normalise raw strings before Pydantic tries to parse them; use mode="after" for logic that needs the typed value.

Pass multiple field names to @field_validator. The validator is called once per field listed:

from pydantic import BaseModel, field_validator

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

    @field_validator("name", "description")
    @classmethod
    def must_not_be_blank(cls, v: str) -> str:
        if not v.strip():
            raise ValueError("must not be blank or whitespace")
        return v.strip()

The validator receives one field value at a time — it does not receive both simultaneously. For cross-field logic, use @model_validator.

Rule of thumb: share validators across fields with multiple names in the decorator to keep DRY; use @model_validator when the logic depends on comparing field values to each other.

@model_validator runs once on the whole model (all fields at once). Use it for cross-field constraints.

from pydantic import BaseModel, model_validator

class DateRange(BaseModel):
    start: date
    end: date

    @model_validator(mode="after")
    def end_after_start(self) -> "DateRange":
        if self.end <= self.start:
            raise ValueError("end must be after start")
        return self

mode="after" — runs after all fields are coerced and validated; self is the model instance. mode="before" — runs before field validation; receives raw data as a dict.

Rule of thumb: @field_validator for single-field logic; @model_validator(mode="after") for cross-field constraints like "end after start" or "confirm password matches".

mode="before" intercepts the raw input before any field parsing. The validator receives a dict (or arbitrary input) and must return a dict.

from pydantic import BaseModel, model_validator

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

    @model_validator(mode="before")
    @classmethod
    def coerce_legacy_format(cls, data):
        # support both {"name": ...} and {"full_name": ...}
        if "full_name" in data and "name" not in data:
            data["name"] = data.pop("full_name")
        return data

Use cases: renaming legacy fields, setting computed defaults before field validation, accepting multiple input shapes.

Rule of thumb: mode="before" is for input normalisation at the whole-model level; prefer mode="after" for validation because you get typed values.

Annotate a class with __get_validators__ (v1) or implement __get_pydantic_core_schema__ (v2). For simple cases, use Annotated with AfterValidator:

from typing import Annotated
from pydantic import AfterValidator, BaseModel

def validate_isbn(v: str) -> str:
    v = v.replace("-", "")
    if len(v) not in (10, 13):
        raise ValueError("ISBN must be 10 or 13 digits")
    return v

ISBN = Annotated[str, AfterValidator(validate_isbn)]

class Book(BaseModel):
    title: str
    isbn: ISBN

For full custom types with JSON Schema:

from pydantic import GetCoreSchemaHandler
from pydantic_core import core_schema

class PositiveDecimal:
    @classmethod
    def __get_pydantic_core_schema__(cls, source, handler: GetCoreSchemaHandler):
        return core_schema.no_info_after_validator_function(cls, core_schema.decimal_schema())

Rule of thumb: use Annotated[T, AfterValidator(fn)] for simple reusable validators; implement __get_pydantic_core_schema__ only for complex custom types.

ValidationError has an .errors() method returning a list of error dicts:

from pydantic import BaseModel, ValidationError

class Item(BaseModel):
    name: str
    price: float

try:
    Item(name="", price=-1)
except ValidationError as e:
    print(e.errors())
# [
#   {"type": "value_error", "loc": ("name",), "msg": "...", "input": ""},
#   {"type": "greater_than", "loc": ("price",), "msg": "...", "input": -1}
# ]

Each error has:

  • type — Pydantic error code
  • loc — tuple of keys pinpointing the failing field
  • msg — human-readable message
  • input — the value that failed

FastAPI captures this and returns it as the 422 response detail array.

Rule of thumb: in unit tests, assert on exc.errors()[0]["type"] to test specific error codes rather than message strings that may change.

Strict mode disables Pydantic's type coercion — values must already be the correct Python type; no implicit conversion happens.

from pydantic import BaseModel, ConfigDict

class StrictItem(BaseModel):
    model_config = ConfigDict(strict=True)
    price: float
    count: int

StrictItem(price=9.99, count=3)     # OK
StrictItem(price="9.99", count=3)   # ValidationError — "9.99" is str, not float

Per-field: Field(strict=True) or Strict annotated type.

Use cases: data coming from other Python code (not raw JSON) where silent coercion hides bugs; internal domain objects that must have exact types.

Rule of thumb: don't use strict mode for API request models — clients send JSON where numbers may arrive as strings; use it for internal/DTO models between Python layers.

Pass skip_on_failure=True (for mode="after") or check for None at the top of the validator. With mode="before" and optional fields, validators run even on None inputs by default.

from pydantic import BaseModel, field_validator

class Profile(BaseModel):
    bio: str | None = None

    @field_validator("bio", mode="after")
    @classmethod
    def bio_max_sentences(cls, v: str | None) -> str | None:
        if v is None:
            return v    # skip check
        if v.count(".") > 5:
            raise ValueError("bio may not exceed 5 sentences")
        return v

Alternatively, in Pydantic v2 you can annotate with Optional inside Annotated and chain with BeforeValidator that returns None early.

Rule of thumb: always guard against None at the top of validators for optional fields — a missed check causes a confusing AttributeError.

Yes — the return value of a @field_validator replaces the field's value. Forgetting to return v silently sets the field to None.

# WRONG — returns None, field becomes None
@field_validator("name")
@classmethod
def check_name(cls, v: str) -> str:
    if not v:
        raise ValueError("required")
    # forgot return v!

# CORRECT
@field_validator("name")
@classmethod
def check_name(cls, v: str) -> str:
    if not v:
        raise ValueError("required")
    return v   # must return the (possibly transformed) value

Rule of thumb: end every @field_validator with a return v — validators that only validate (no transform) still must return the original value.

These are functional wrappers used inside Annotated to attach validators without subclassing BaseModel:

from typing import Annotated
from pydantic import BaseModel, BeforeValidator, AfterValidator

def strip_spaces(v: str) -> str:
    return v.strip()

def ensure_lower(v: str) -> str:
    return v.lower()

CleanStr = Annotated[str, BeforeValidator(strip_spaces), AfterValidator(ensure_lower)]

class User(BaseModel):
    username: CleanStr

This creates reusable annotated types you can import and use across models without copy-pasting validators.

Rule of thumb: define common transformations as Annotated types (Email, SlugStr, PositiveDecimal) and import them — it's DRY and keeps validators out of model classes.

Accept both models as body parameters and validate their relationship in the handler (or in a dedicated service function):

class DateRangeFilter(BaseModel):
    start: date
    end: date

    @model_validator(mode="after")
    def end_after_start(self) -> "DateRangeFilter":
        if self.end < self.start:
            raise ValueError("end must be >= start")
        return self

class ReportRequest(BaseModel):
    range: DateRangeFilter
    metrics: list[str]

@app.post("/reports")
async def generate_report(req: ReportRequest):
    ...

For cross-model checks that span the body and path/query params, perform the check in the handler and raise HTTPException(422, ...).

Rule of thumb: encode single-model invariants in @model_validator; encode cross-model or cross-layer invariants in the handler or service layer.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel