Skip to content

Validation -- Pydantic v2 Patterns

validation

Pydantic v2 schema validation — with inline Zod (TypeScript) comparisons.

Covers
  1. BaseModel with field constraints and custom validators.
  2. Nested models and model-level cross-field validation.
  3. Discriminated unions for polymorphic data.
  4. Serialization / deserialization helpers.
Why validation matters at API boundaries

Static type checkers (mypy, pyright) verify structure at development time, but they cannot guard against bad data arriving at runtime over the network. Pydantic fills that gap — it validates values, coerces types, and produces clear error messages, all with minimal boilerplate.

Pydantic v2 vs v1

v2 rewrote the core in Rust (pydantic-core), yielding 5--50x speedups. The API shifted: model_validate replaces parse_obj, model_dump replaces .dict(), and model_config (a class var) replaces the inner Config class.

References

https://docs.pydantic.dev/latest/ https://zod.dev/ (TypeScript counterpart — see inline comparisons)

Address

Bases: BaseModel

A US-style mailing address.

Source code in src/concepts/validation.py
class Address(BaseModel):
    """A US-style mailing address."""

    street: str = Field(min_length=1)
    city: str = Field(min_length=1)
    state: str = Field(min_length=2, max_length=2)
    zip_code: str = Field(pattern=r"^\d{5}$")

User

Bases: BaseModel

A user account with nested address and optional tags.

Source code in src/concepts/validation.py
class User(BaseModel):
    """A user account with nested address and optional tags."""

    name: str
    # We skip EmailStr to avoid the extra ``email-validator`` dependency.
    # Instead, a field_validator does a minimal "@" check.
    email: str
    age: int = Field(ge=0, le=150)
    address: Address
    tags: list[str] = Field(default_factory=list)

    # --- Field-level validator ---
    # Pydantic v2 field validators receive the value *after* core
    # validation (type coercion) but *before* model validators.
    @field_validator("email")
    @classmethod
    def email_must_contain_at(cls, v: str) -> str:
        """Ensure the email contains an '@' character."""
        if "@" not in v:
            msg = "email must contain '@'"
            raise ValueError(msg)
        return v

    # --- Model-level (cross-field) validator ---
    # Runs after all field validators.  ``mode="after"`` means `self`
    # is a fully-constructed User instance.
    @model_validator(mode="after")
    def check_admin_age(self) -> User:
        """Reject users under 18 who carry the 'admin' tag."""
        if self.age < 18 and "admin" in self.tags:
            msg = "users under 18 cannot be admins"
            raise ValueError(msg)
        return self

email_must_contain_at classmethod

email_must_contain_at(v: str) -> str

Ensure the email contains an '@' character.

Source code in src/concepts/validation.py
@field_validator("email")
@classmethod
def email_must_contain_at(cls, v: str) -> str:
    """Ensure the email contains an '@' character."""
    if "@" not in v:
        msg = "email must contain '@'"
        raise ValueError(msg)
    return v

check_admin_age

check_admin_age() -> User

Reject users under 18 who carry the 'admin' tag.

Source code in src/concepts/validation.py
@model_validator(mode="after")
def check_admin_age(self) -> User:
    """Reject users under 18 who carry the 'admin' tag."""
    if self.age < 18 and "admin" in self.tags:
        msg = "users under 18 cannot be admins"
        raise ValueError(msg)
    return self

Circle

Bases: BaseModel

A circle, identified by shape_type = "circle".

Source code in src/concepts/validation.py
class Circle(BaseModel):
    """A circle, identified by ``shape_type = "circle"``."""

    shape_type: Literal["circle"]
    radius: float = Field(gt=0)

Rectangle

Bases: BaseModel

A rectangle, identified by shape_type = "rectangle".

Source code in src/concepts/validation.py
class Rectangle(BaseModel):
    """A rectangle, identified by ``shape_type = "rectangle"``."""

    shape_type: Literal["rectangle"]
    width: float = Field(gt=0)
    height: float = Field(gt=0)

serialize_user

serialize_user(user: User) -> dict[str, object]

Convert a User to a plain dict (JSON-safe).

Source code in src/concepts/validation.py
def serialize_user(user: User) -> dict[str, object]:
    """Convert a User to a plain dict (JSON-safe)."""
    return user.model_dump()

deserialize_user

deserialize_user(data: dict[str, object]) -> User

Parse and validate data into a User instance.

Raises pydantic.ValidationError on bad input.

Source code in src/concepts/validation.py
def deserialize_user(data: dict[str, object]) -> User:
    """Parse and validate *data* into a User instance.

    Raises ``pydantic.ValidationError`` on bad input.
    """
    return User.model_validate(data)