Pydantic v2 for API validation and settings

Models, validators, computed fields, and pydantic-settings for typed configuration.

๐ŸŒ Module 8 8 min read Not started

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

  1. Define models with field constraints.
  2. Use field and model validators.
  3. Use aliases, computed fields, model serializers.
  4. Use pydantic-settings for config.
  5. Tune performance (strict mode, JSON schema, custom types).

1. Setup

powershell
uv add "pydantic>=2" "pydantic-settings>=2" "email-validator"
python
from pydantic import BaseModel, Field, EmailStr

2. Models and field constraints

python
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

python
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 -> Decimal

By default Pydantic coerces compatible types. To require exact types:

python
class StrictUser(BaseModel):
    model_config = {"strict": True}
    age: int

Now passing "30" (string) raises.

From dict / from JSON

python
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

python
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

python
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 v

mode="before" runs before standard validation (raw input). mode="after" (default) runs after coercion.

@model_validator

For checks that span fields:

python
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 self

Use mode="before" to manipulate the raw input dict (e.g., support legacy field names).

@computed_field

python
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

python
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

python
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

python
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: UpperStr

Reusable typed aliases keep schemas DRY.


8. Discriminated unions

python
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

python
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+):

python
class Page[T](BaseModel):
    items: list[T]
    total: int

Generics give you Response[T], Page[T], etc., without duplicating models per resource.


10. Custom types

python
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: Money

Custom types let you use domain objects directly in models and still get full validation + serialisation.


11. JSON Schema generation

python
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:

python
User.model_json_schema(mode="serialization")
User.model_json_schema(mode="validation")

12. pydantic-settings โ€” typed config from env

python
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, types

Use this everywhere instead of os.getenv("X"). Single source of truth, validated at startup.


13. Performance tips

  • Use model_validate_json(raw) instead of json.loads(raw); model_validate(...).
  • Cache models that are re-built repeatedly (@lru_cache on model factories).
  • strict=True is 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) or NamedTuple.
  • 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:

v1v2
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_namepopulate_by_name
Generic via GenericModelBaseModel + Generic directly

Use bump-pydantic tool for automated migration.


15. Worked example: an LLM tool-call schema

python
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)

  1. Define a UserCreateRequest with all common constraints; test with valid + invalid inputs.
  2. Add a @field_validator to normalise email to lower-case.
  3. Add a @model_validator(mode="after") ensuring password == password_confirm.
  4. Build a discriminated union (e.g., Event = Login | Logout | Click).
  5. Create a generic ApiResponse[T] model with data: T and status: Literal["ok", "error"].
  6. Build a Settings class loading from .env with nested config.
  7. Bonus: write a custom type for PhoneNumber with validation + serialisation.

Common pitfalls

  1. Using v1 syntax (@validator, class Config) in a v2 project.
  2. Forgetting extra="forbid" on public-facing models โ†’ silent typos.
  3. Mutating models after creation when frozen=True should have been used.
  4. model_dump() returning datetime objects when JSON-friendly was expected โ€” use mode="json".
  5. Putting business logic in validators instead of services.
  6. Bringing Pydantic into hot internal paths where dataclasses would do.

Self-check

  1. What does model_validate_json do better than json.loads + model_validate?
  2. Difference between @field_validator and @model_validator?
  3. When use discriminator?
  4. How does pydantic-settings find env vars?
  5. Why use Annotated for reusable validations?

References

Sign in to save your progress and earn badges.