Pydantic v2 schema validation — with inline Zod (TypeScript) comparisons.
Covers
- BaseModel with field constraints and custom validators.
- Nested models and model-level cross-field validation.
- Discriminated unions for polymorphic data.
- 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)
|