Skip to the solution
MediumBackend12 min read

How does Pydantic v2 validation work (BaseModel, field_validator, mode)?

1.1k views

The problem

Question presented to candidate: "Walk me through what happens when you create a Pydantic v2 model from a dict. Where do type conversion and your own field_validators run, and what is the difference between the before, after, wrap and plain modes?"

What a strong answer should cover:

  • BaseModel reads the class annotations and builds a validator (a "core schema") once, at class-creation time; the work runs in pydantic-core, written in Rust. Model(**data), model_validate(dict) and model_validate_json(str) all go through it.
  • Default lax mode coerces sensible inputs ("42" to 42, a tuple to a list, "yes" to True) but rejects lossy ones (3.5 for an int); strict=True turns coercion off.
  • Validation errors are collected per field and raised together as one ValidationError with loc, type and msg per error.
  • field_validator modes: before runs on the raw input before type validation, after (the default) on the already-typed value, wrap gets a handler to call the inner validation itself, plain replaces the type validation completely.
  • model_validator(mode="before"/"after") sees the whole input or the finished model, for cross-field rules; ValidationInfo.data gives a field validator the fields validated before it.
  • Practical details: several before validators run last-defined first, after validators in definition order; assignment is not validated unless validate_assignment=True; model_construct skips validation.

Clarifying questions expected:

  • Is the input trusted Python data or external JSON (which affects strict mode and model_validate_json)?
  • Should the model coerce types, or reject anything not already the right type?

Code / implementation expected: Yes โ€” a small model with one before and one after validator, and the ValidationError for bad input.

  • pydantic
  • validation
  • python3.11

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

12 min read ยท 12 sections

Run the code

Edit the code and run it here. Each console line with an expected value becomes a test.

PythonA standard-library model of the validation pipeline (runs in the page)
PythonReal Pydantic v2: BaseModel, before/after/wrap validators, ValidationError
# Needs pydantic v2 (pip install pydantic). Verified with pydantic 2.13.5 on Python 3.13.
from typing import Any

import pydantic
from pydantic import BaseModel, Field, ValidationError, ValidationInfo, field_validator, model_validator

print("pydantic", pydantic.VERSION)


class SignUp(BaseModel):
    id: int
    name: str
    email: str
    tags: list[str] = []
    age: int = Field(default=0, ge=0, le=150)
    password: str
    password_again: str

    @field_validator("name", mode="before")
    @classmethod
    def strip_name(cls, v: Any) -> Any:
        print("  before(name) got", repr(v))
        return v.strip() if isinstance(v, str) else v

    @field_validator("name")                     # mode="after" is the default
    @classmethod
    def title_name(cls, v: str) -> str:
        print("  after(name) got", repr(v))
        return v.title()

    @field_validator("email", mode="wrap")
    @classmethod
    def lower_email(cls, v: Any, handler) -> str:
        print("  wrap(email) in ", repr(v))
        result = handler(v)                      # run the normal str validation
        print("  wrap(email) out", repr(result))
        return result.lower()

    @field_validator("password_again")
    @classmethod
    def same_password(cls, v: str, info: ValidationInfo) -> str:
        if v != info.data.get("password"):     # fields validated earlier
            raise ValueError("passwords do not match")
        return v

    @model_validator(mode="after")
    def whole_model(self) -> "SignUp":
        print("  model after: all fields valid")
        return self


print("valid input:")
u = SignUp(id="42", name="  ada lovelace ", email="ADA@Example.ORG",
           tags=("math", "engines"), password="pw", password_again="pw")
print(u.model_dump(exclude={"password", "password_again"}))
print(type(u.id).__name__, type(u.tags).__name__)

print("invalid input:")
try:
    SignUp(id="4x", name=5, email="e", age=-1, password="a", password_again="b")
except ValidationError as e:
    print(e.error_count(), "errors")
    for err in e.errors():
        print(" ", err["loc"], err["type"], "-", err["msg"])

print("from JSON:")
print(SignUp.model_validate_json(
    '{"id": 7, "name": "grace", "email": "G@X.IO", "password": "p", "password_again": "p"}'
).name)
PythonValidator order, strict vs lax, plain mode and validate_assignment
# Needs pydantic v2 (pip install pydantic). Verified with pydantic 2.13.5 on Python 3.13.
from typing import Annotated

from pydantic import (AfterValidator, BaseModel, BeforeValidator, ConfigDict,
                      PlainValidator, ValidationError, WrapValidator)


def tag(name):
    def check(v):
        print("   ", name, repr(v))
        return v
    return check


def wrap_tag(name):
    def check(v, handler):
        print("   ", name, "in ", repr(v))
        out = handler(v)
        print("   ", name, "out", repr(out))
        return out
    return check


# 1. Order of several validators on one field (Annotated form).
class Order(BaseModel):
    x: Annotated[int,
                 BeforeValidator(tag("before-1")),
                 AfterValidator(tag("after-1")),
                 WrapValidator(wrap_tag("wrap-1")),
                 BeforeValidator(tag("before-2")),
                 AfterValidator(tag("after-2"))]


print("validator order for x='7':")
Order(x="7")


# 2. Lax (default) vs strict mode.
class Lax(BaseModel):
    i: int
    f: float
    b: bool


class Strict(BaseModel):
    model_config = ConfigDict(strict=True)
    i: int


print(Lax(i="3", f="1.5", b="yes"))
try:
    Lax(i=3.5, f=1, b="maybe")
except ValidationError as e:
    print("lax rejects:", [(err["loc"][0], err["type"]) for err in e.errors()])
try:
    Strict(i="3")
except ValidationError as e:
    print("strict rejects '3':", e.errors()[0]["type"])
print("strict JSON accepts:", Strict.model_validate_json('{"i": 3}'))


# 3. mode="plain" replaces the core validation entirely.
class Plain(BaseModel):
    x: Annotated[int, PlainValidator(lambda v: v)]


print("plain keeps a str in an int field:", repr(Plain(x="not an int").x))


# 4. Assignment is not validated unless you ask for it.
class Loose(BaseModel):
    n: int


class Checked(BaseModel):
    model_config = ConfigDict(validate_assignment=True)
    n: int


a = Loose(n=1)
a.n = "oops"
print("Loose after assignment:", repr(a.n))
c = Checked(n=1)
c.n = "2"
print("Checked coerced:", repr(c.n))
try:
    c.n = "two"
except ValidationError as e:
    print("Checked rejects:", e.errors()[0]["type"])

# 5. model_construct skips validation completely (trusted data only).
print("model_construct:", repr(Loose.model_construct(n="zz").n))

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 43 of 139 in Python

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All Python questions
Up nextMedium

What is the difference between bytes and str?

Continue