Skip to content

Pydantic V2 Reference: BaseModel, Validators, Settings, Serialization & TypeAdapter

Pydantic V2 was rewritten in Rust and is 5-50x faster than V1. The API is similar but there are breaking changes: @validator is replaced by @field_validator, Config class becomes model_config, and .dict() becomes .model_dump(). Pydantic is used everywhere data needs to be validated and typed: FastAPI request/response models, settings management, dataclass replacement, and LLM structured outputs.

1. BaseModel, Field & Type Annotations

Define models, use Field() for constraints, and understand coercion vs strict mode
from pydantic import BaseModel, Field, EmailStr, HttpUrl
from typing import Annotated
from datetime import datetime

class User(BaseModel):
    id:         int
    name:       str = Field(min_length=1, max_length=100)
    email:      EmailStr                        # validates email format
    website:    HttpUrl | None = None           # validates URL, optional
    age:        int = Field(ge=0, le=150)       # ge=greater-equal, le=less-equal
    score:      float = Field(default=0.0, ge=0.0, le=1.0)
    tags:       list[str] = Field(default_factory=list)
    created_at: datetime = Field(default_factory=datetime.utcnow)

# Annotated: reusable type alias with constraints:
PositiveInt = Annotated[int, Field(gt=0)]
Username    = Annotated[str, Field(min_length=3, max_length=30, pattern=r"^[a-z0-9_]+$")]

class Profile(BaseModel):
    user_id:  PositiveInt
    username: Username

# Instantiation + coercion (Pydantic coerces by default):
user = User(id="42", name="Alice", email="alice@example.com", age="30")
# id="42" → id=42 (str coerced to int)

# Strict mode: no coercion (int stays int, str stays str):
from pydantic import ConfigDict
class StrictUser(BaseModel):
    model_config = ConfigDict(strict=True)
    id: int   # "42" would raise ValidationError

# model_config options:
class Config(BaseModel):
    model_config = ConfigDict(
        str_strip_whitespace=True,    # strip leading/trailing whitespace
        populate_by_name=True,        # allow field name OR alias
        from_attributes=True,         # allow ORM objects (replaces orm_mode)
    )

2. Validators — @field_validator & @model_validator

Field-level and model-level validators, before vs after mode, and @computed_field
from pydantic import BaseModel, field_validator, model_validator, computed_field
from typing import Self

class CreateUserRequest(BaseModel):
    name:             str
    email:            str
    password:         str
    password_confirm: str

    # @field_validator (replaces V1 @validator):
    @field_validator("email")
    @classmethod
    def email_must_be_lowercase(cls, v: str) -> str:
        return v.lower()

    @field_validator("password")
    @classmethod
    def password_strength(cls, v: str) -> str:
        if len(v) < 8:
            raise ValueError("Password must be at least 8 characters")
        if not any(c.isupper() for c in v):
            raise ValueError("Password must contain at least one uppercase letter")
        return v

    # @model_validator: cross-field validation (after all fields are set):
    @model_validator(mode="after")
    def passwords_match(self) -> Self:
        if self.password != self.password_confirm:
            raise ValueError("Passwords do not match")
        return self

    # mode="before": receives raw input before type coercion:
    @field_validator("name", mode="before")
    @classmethod
    def strip_and_title(cls, v):
        if isinstance(v, str):
            return v.strip().title()
        return v

# @computed_field: property included in .model_dump() / .model_json():
class Product(BaseModel):
    price: float
    tax_rate: float = 0.2

    @computed_field
    @property
    def price_with_tax(self) -> float:
        return round(self.price * (1 + self.tax_rate), 2)

3. Serialization & Deserialization

model_dump(), model_dump_json(), model_validate(), aliases, and nested models
from pydantic import BaseModel, Field, AliasGenerator
from pydantic.alias_generators import to_camel

class Address(BaseModel):
    street: str
    city: str
    zip_code: str

class UserResponse(BaseModel):
    model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
    # to_camel: zip_code → zipCode, first_name → firstName

    first_name: str
    last_name: str
    email_address: str
    address: Address    # nested model

# Serialization:
user = UserResponse(first_name="Alice", last_name="Smith",
                    email_address="alice@example.com",
                    address=Address(street="1 Main St", city="London", zip_code="EC1"))

user.model_dump()                    # {"first_name": "Alice", ...} (Python field names)
user.model_dump(by_alias=True)       # {"firstName": "Alice", ...} (camelCase aliases)
user.model_dump(exclude={"address"}) # exclude fields
user.model_dump(include={"first_name", "email_address"})  # include only
user.model_dump(mode="json")         # JSON-safe types (datetime → ISO string)
user.model_dump_json()               # JSON string directly

# Deserialization from dict/JSON:
user = UserResponse.model_validate({"firstName": "Alice", ...})  # from alias
user = UserResponse.model_validate_json('{"firstName": "Alice", ...}')  # from JSON string

# From ORM object (from_attributes=True):
class UserResponse(BaseModel):
    model_config = ConfigDict(from_attributes=True)
    id: int
    name: str
user = UserResponse.model_validate(orm_user_object)

4. Settings Management (pydantic-settings)

Load config from .env files, environment variables, and multiple sources
# pip install pydantic-settings
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import SecretStr, PostgresDsn

class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        case_sensitive=False,
        extra="ignore",          # ignore env vars not in model
    )

    # Required (no default — fails if not set):
    database_url: PostgresDsn
    secret_key: SecretStr       # value hidden in repr/logs

    # Optional with defaults:
    debug: bool = False
    port: int = 8000
    allowed_origins: list[str] = ["http://localhost:3000"]
    environment: str = "development"

    # Nested settings (reads DATABASE__HOST etc.):
    # model_config = SettingsConfigDict(env_nested_delimiter="__")

# .env file:
# DATABASE_URL=postgresql://user:pass@localhost/db
# SECRET_KEY=supersecret
# DEBUG=true
# ALLOWED_ORIGINS=["https://app.example.com","https://admin.example.com"]

settings = Settings()
print(settings.secret_key.get_secret_value())  # access SecretStr value

# Singleton pattern for FastAPI:
from functools import lru_cache

@lru_cache
def get_settings() -> Settings:
    return Settings()

# In route: settings: Settings = Depends(get_settings)

5. Error Handling & Advanced Patterns

ValidationError, discriminated unions, TypeAdapter for non-model validation
from pydantic import BaseModel, ValidationError, TypeAdapter
from typing import Literal, Union, Annotated

# ValidationError: structured error with field paths:
try:
    User(id="not-a-number", email="bad-email")
except ValidationError as e:
    print(e.error_count())     # number of errors
    for err in e.errors():
        print(err["loc"])      # ("id",) or ("email",)
        print(err["msg"])      # "Input should be a valid integer"
        print(err["type"])     # "int_parsing"
    # FastAPI automatically converts ValidationError to 422 response

# Discriminated union (efficient parsing when you have subtypes):
class Cat(BaseModel):
    type: Literal["cat"]
    indoor: bool

class Dog(BaseModel):
    type: Literal["dog"]
    breed: str

Pet = Annotated[Union[Cat, Dog], Field(discriminator="type")]

class PetOwner(BaseModel):
    name: str
    pet: Pet

owner = PetOwner(name="Alice", pet={"type": "dog", "breed": "Labrador"})
# pet is parsed as Dog, not Cat — O(1) lookup via type discriminator

# TypeAdapter: validate non-model types:
ta = TypeAdapter(list[int])
result = ta.validate_python(["1", "2", "3"])   # [1, 2, 3] (coerced)
ta.validate_python(["a", "b"])                 # ValidationError

# TypeAdapter for JSON schema:
ta = TypeAdapter(list[User])
print(ta.json_schema())

Track Python and Pydantic releases at ReleaseRun. Related: FastAPI Reference | Python asyncio Reference | SQLAlchemy 2.0 Reference | Python EOL Tracker

🔍 Free tool: PyPI Package Health Checker — check Pydantic v1/v2, pydantic-settings, and email-validator for known CVEs and latest versions.

Founded

2023 in London, UK

Contact

hello@releaserun.com