Pydantic v2 for API validation and settings
Models, validators, computed fields, and pydantic-settings for typed configuration.
Why this matters
Pydantic v2 (Rust-core, ~10โ50ร faster than v1) is the parsing/validation layer for FastAPI, LangChain, instructor, every modern Python API. Mastering it removes a whole category of "the input was malformed and we crashed at line 200" bugs.
Learning objectives
- Define models with field constraints.
- Use field and model validators.
- Use aliases, computed fields, model serializers.
- Use
pydantic-settingsfor config. - Tune performance (strict mode, JSON schema, custom types).
1. Setup
uv add "pydantic>=2" "pydantic-settings>=2" "email-validator"from pydantic import BaseModel, Field, EmailStr2. Models and field constraints
from pydantic import BaseModel, Field, EmailStr, HttpUrl, PositiveInt, SecretStr, UUID4
from datetime import datetime
from decimal import Decimal
class User(BaseModel):
id: UUID4
name: str = Field(min_length=1, max_length=50, pattern=r"^[\w \-']+$")
age: int = Field(ge=0, lt=150)
email: EmailStr
website: HttpUrl | None = None
role: str = "user"
api_key: SecretStr # masked in repr / dict
created_at: datetime = Field(default_factory=datetime.utcnow)
balance: Decimal = Field(max_digits=12, decimal_places=2, ge=0)Built-in constraint types:
- Strings:
min_length,max_length,pattern. - Numbers:
gt,ge,lt,le,multiple_of. - Decimals:
max_digits,decimal_places. - Collections:
min_length,max_length.
Specialty types:
EmailStr,HttpUrl,IPvAnyAddress,UUID4,Json.PositiveInt,NonNegativeInt,NegativeFloat.SecretStr,SecretBytes.FilePath,DirectoryPath,NewPath.
3. Validation behaviour
u = User.model_validate({"id": "...", "name": "Ada", "age": "30", "email": "ada@x.com", "api_key": "secret", "balance": "12.34"})
# age coerced from str -> int; balance from str -> DecimalBy default Pydantic coerces compatible types. To require exact types:
class StrictUser(BaseModel):
model_config = {"strict": True}
age: intNow passing "30" (string) raises.
From dict / from JSON
User.model_validate(some_dict) # dict in
User.model_validate_json(raw_bytes_or_str) # JSON in (faster โ single parse + validate)model_validate_json is up to 2x faster than json.loads + model_validate because it parses+validates in a single Rust pass.
To dict / to JSON
u.model_dump() # dict (incl. unset fields)
u.model_dump(exclude_unset=True) # only fields the caller actually set
u.model_dump(by_alias=True)
u.model_dump(exclude={"api_key"})
u.model_dump(mode="json") # types coerced to JSON-friendly (datetime -> str)
u.model_dump_json(indent=2)4. Validators
@field_validator
from pydantic import field_validator
class Order(BaseModel):
items: list[str]
total: float
@field_validator("items")
@classmethod
def at_least_one(cls, v):
if not v: raise ValueError("items cannot be empty")
return v
@field_validator("total")
@classmethod
def positive(cls, v):
if v <= 0: raise ValueError("total must be positive")
return vmode="before" runs before standard validation (raw input). mode="after" (default) runs after coercion.
@model_validator
For checks that span fields:
from pydantic import model_validator
class DateRange(BaseModel):
start: datetime
end: datetime
@model_validator(mode="after")
def ordered(self):
if self.end < self.start:
raise ValueError("end before start")
return selfUse mode="before" to manipulate the raw input dict (e.g., support legacy field names).
@computed_field
from pydantic import computed_field
class Rect(BaseModel):
w: float
h: float
@computed_field
@property
def area(self) -> float:
return self.w * self.h
Rect(w=2, h=3).model_dump() # {'w': 2.0, 'h': 3.0, 'area': 6.0}Computed fields appear in serialised output (and JSON schema) without being a real attribute.
5. Aliases and field names
from pydantic import BaseModel, Field, ConfigDict
class User(BaseModel):
model_config = ConfigDict(populate_by_name=True)
name: str = Field(alias="full_name")
age: int = Field(alias="years")
User.model_validate({"full_name": "Ada", "years": 30}).name # "Ada"
User.model_validate({"name": "Ada", "age": 30}).name # also works (populate_by_name)Aliases let you accept "external" field names while keeping internal Python names. Common for:
- Renaming legacy fields.
- Snake-case Python โ camelCase JSON.
For round-trip serialisation, model_dump(by_alias=True).
6. Model config
from pydantic import ConfigDict
class M(BaseModel):
model_config = ConfigDict(
frozen=True, # immutable after creation
extra="forbid", # error on unknown fields (also: "ignore", "allow")
str_strip_whitespace=True,
str_to_lower=True,
populate_by_name=True,
use_enum_values=True,
validate_assignment=True, # re-validate on attribute set
json_schema_extra={"example": {...}},
)Sensible defaults for public APIs: extra="forbid", str_strip_whitespace=True. Catches typos in field names early.
7. Annotated + reusable types
from typing import Annotated
from pydantic import Field, AfterValidator
NonEmpty = Annotated[str, Field(min_length=1)]
Username = Annotated[str, Field(min_length=3, max_length=30, pattern=r"^[a-z0-9_]+$")]
def upper(s: str) -> str: return s.upper()
UpperStr = Annotated[str, AfterValidator(upper)]
class User(BaseModel):
name: NonEmpty
username: Username
country_code: UpperStrReusable typed aliases keep schemas DRY.
8. Discriminated unions
from typing import Literal
from pydantic import BaseModel, Field
class Cat(BaseModel):
kind: Literal["cat"] = "cat"
meow: str
class Dog(BaseModel):
kind: Literal["dog"] = "dog"
bark: str
Pet = Annotated[Cat | Dog, Field(discriminator="kind")]
class Owner(BaseModel):
pet: Pet
Owner.model_validate({"pet": {"kind": "cat", "meow": "purr"}})The discriminator makes validation fast (no try-all) and produces a clean JSON schema. Powers polymorphic APIs cleanly.
9. Generic models
from typing import Generic, TypeVar
from pydantic import BaseModel
T = TypeVar("T")
class Page(BaseModel, Generic[T]):
items: list[T]
total: int
page: int
per_page: int
class User(BaseModel):
id: int
name: str
UserPage = Page[User]
UserPage.model_validate({"items": [{"id": 1, "name": "Ada"}], "total": 1, "page": 1, "per_page": 10})PEP 695 syntax (3.12+):
class Page[T](BaseModel):
items: list[T]
total: intGenerics give you Response[T], Page[T], etc., without duplicating models per resource.
10. Custom types
from pydantic import GetCoreSchemaHandler
from pydantic_core import core_schema
from typing import Any
class Money:
def __init__(self, cents: int, currency: str):
self.cents = cents; self.currency = currency
@classmethod
def __get_pydantic_core_schema__(cls, source_type: Any, handler: GetCoreSchemaHandler):
def validate(v):
if isinstance(v, Money): return v
if isinstance(v, dict): return cls(v["cents"], v["currency"])
raise ValueError("expected Money or {cents, currency}")
def serialize(v: "Money"):
return {"cents": v.cents, "currency": v.currency}
return core_schema.no_info_plain_validator_function(
validate,
serialization=core_schema.plain_serializer_function_ser_schema(serialize),
)
class Order(BaseModel):
total: MoneyCustom types let you use domain objects directly in models and still get full validation + serialisation.
11. JSON Schema generation
User.model_json_schema()Returns OpenAPI-compatible schema. FastAPI uses this to render /docs. Customise via Field(json_schema_extra=...) or model-level json_schema_extra.
For different rendering modes:
User.model_json_schema(mode="serialization")
User.model_json_schema(mode="validation")12. pydantic-settings โ typed config from env
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
env_prefix="APP_",
env_nested_delimiter="__", # APP_DB__HOST -> db.host
)
api_key: str
debug: bool = False
db: "DBConfig"
class DBConfig(BaseModel):
host: str
port: int = 5432
cfg = Settings() # reads env + .env, validates, typesUse this everywhere instead of os.getenv("X"). Single source of truth, validated at startup.
13. Performance tips
- Use
model_validate_json(raw)instead ofjson.loads(raw); model_validate(...). - Cache models that are re-built repeatedly (
@lru_cacheon model factories). strict=Trueis slightly faster than coercion mode (no string-to-int attempts).- For very hot validation paths, profile โ Pydantic v2 is usually fast enough but big nested models still add up.
- For "many tiny objects" patterns where you don't need validation, use
dataclass(slots=True)orNamedTuple. msgspec(smaller, even faster) is an alternative when JSON throughput is critical and you don't need all Pydantic features.
14. Migration from v1
Most common changes:
| v1 | v2 |
|---|---|
class Config: ... | model_config = ConfigDict(...) |
@validator | @field_validator |
@root_validator | @model_validator(mode="before"/"after") |
Model.parse_obj(d) | Model.model_validate(d) |
Model.parse_raw(s) | Model.model_validate_json(s) |
.dict() | .model_dump() |
.json() | .model_dump_json() |
__fields__ | model_fields |
allow_population_by_field_name | populate_by_name |
Generic via GenericModel | BaseModel + Generic directly |
Use bump-pydantic tool for automated migration.
15. Worked example: an LLM tool-call schema
from pydantic import BaseModel, Field
from typing import Literal
class GetWeatherParams(BaseModel):
location: str = Field(description="City, country", min_length=1)
units: Literal["metric", "imperial"] = "metric"
class CreateCalendarParams(BaseModel):
title: str = Field(min_length=1, max_length=200)
start: datetime
end: datetime
attendees: list[EmailStr] = []
# Generate JSON schema for tool descriptions
weather_schema = GetWeatherParams.model_json_schema()
calendar_schema = CreateCalendarParams.model_json_schema()
# Validate LLM output
raw_args = '{"location": "Paris, FR", "units": "metric"}'
parsed = GetWeatherParams.model_validate_json(raw_args)The model_json_schema() is exactly what you give to OpenAI / Anthropic / etc. tool definitions. The model_validate_json is how you safely consume their structured output.
Hands-on lab (1.5 hours)
- Define a
UserCreateRequestwith all common constraints; test with valid + invalid inputs. - Add a
@field_validatorto normalise email to lower-case. - Add a
@model_validator(mode="after")ensuringpassword == password_confirm. - Build a discriminated union (e.g.,
Event = Login | Logout | Click). - Create a generic
ApiResponse[T]model withdata: Tandstatus: Literal["ok", "error"]. - Build a
Settingsclass loading from.envwith nested config. - Bonus: write a custom type for
PhoneNumberwith validation + serialisation.
Common pitfalls
- Using v1 syntax (
@validator,class Config) in a v2 project. - Forgetting
extra="forbid"on public-facing models โ silent typos. - Mutating models after creation when
frozen=Trueshould have been used. model_dump()returning datetime objects when JSON-friendly was expected โ usemode="json".- Putting business logic in validators instead of services.
- Bringing Pydantic into hot internal paths where dataclasses would do.
Self-check
- What does
model_validate_jsondo better thanjson.loads + model_validate? - Difference between
@field_validatorand@model_validator? - When use
discriminator? - How does
pydantic-settingsfind env vars? - Why use
Annotatedfor reusable validations?
References
- Pydantic v2 docs: https://docs.pydantic.dev/latest/.
- Pydantic-settings: https://docs.pydantic.dev/latest/concepts/pydantic_settings/.
- "Pydantic v2: 17ร faster" โ Samuel Colvin's blog.
- msgspec: https://jcristharif.com/msgspec/.
- bump-pydantic: https://github.com/pydantic/bump-pydantic.
Sign in to save your progress and earn badges.