Skip to content

Latest commit

 

History

History
1331 lines (1035 loc) · 29.7 KB

File metadata and controls

1331 lines (1035 loc) · 29.7 KB
name pydantic
description Data validation using Python type hints with Pydantic models, settings, serialization, and performance optimization
metadata
author version tags
mte90
1.0.0
python
validation
pydantic
serialization
settings

Pydantic

Data validation using Python type annotations.

Overview

Pydantic is a Python library that provides data validation and settings management using Python type annotations. It validates data at runtime and generates JSON Schema for automatic documentation.

Key Features:

  • Data validation using type hints
  • Automatic data coercion
  • JSON Schema generation
  • Serialization/deserialization
  • Settings management with environment variables
  • Fast performance (especially v2)
  • Extensive customization options

Installation

# Basic installation
pip install pydantic

# With email validation
pip install pydantic[email]

# With best performance (recommended)
pip install pydantic[email] orjson

# Version 2 (current)
pip install pydantic>=2.0.0

Basic Models

Creating Models

from pydantic import BaseModel, Field
from typing import Optional, List

class User(BaseModel):
    """Basic user model."""
    id: int
    name: str
    email: str
    age: Optional[int] = None
    is_active: bool = True

# Create instance
user = User(id=1, name="John", email="john@example.com")
print(user)
# id=1 name='John' email='john@example.com' age=None is_active=True

# From dictionary
data = {
    "id": 2,
    "name": "Jane",
    "email": "jane@example.com",
    "age": 25
}
user = User(**data)

# From JSON
json_data = '{"id": 3, "name": "Bob", "email": "bob@example.com"}'
user = User.model_validate_json(json_data)

Field Types

from pydantic import BaseModel
from typing import Optional, List, Dict, Set, Tuple, Union, Any

class AllTypesExample(BaseModel):
    # Primitives
    string: str
    integer: int
    floating: float
    boolean: bool
    bytes: bytes
    
    # Optional
    optional_string: Optional[str] = None
    optional_with_default: Optional[int] = 42
    
    # Collections
    list_of_strings: List[str] = []
    list_of_ints: List[int]
    dict_data: Dict[str, int] = {}
    set_of_ints: Set[int] = set()
    tuple_data: Tuple[int, str, float] = (1, "a", 1.5)
    
    # Union
    union_field: Union[int, str] = 1
    
    # Any
    any_field: Any = "anything"
    
    # Literal
    from typing import Literal
    status: Literal["pending", "active", "completed"] = "pending"

Nested Models

from pydantic import BaseModel
from typing import List, Optional

class Address(BaseModel):
    street: str
    city: str
    country: str
    postal_code: Optional[str] = None

class Company(BaseModel):
    name: str
    address: Address
    employees: List[str] = []

class Person(BaseModel):
    name: str
    email: str
    address: Optional[Address] = None
    company: Optional[Company] = None

# Create nested data
person = Person(
    name="John",
    email="john@example.com",
    address=Address(
        street="123 Main St",
        city="New York",
        country="USA"
    ),
    company=Company(
        name="Tech Corp",
        address=Address(
            street="456 Business Ave",
            city="San Francisco",
            country="USA"
        ),
        employees=["Alice", "Bob"]
    )
)

Field Validation

Field Constraints

from pydantic import BaseModel, Field, field_validator
from typing import Optional
import re

class ConstrainedUser(BaseModel):
    # String constraints
    name: str = Field(min_length=1, max_length=100)
    username: str = Field(pattern=r'^[a-zA-Z0-9_]+$')
    email: str = Field(format="email")
    
    # Number constraints
    age: int = Field(ge=0, le=150)  # greater or equal, less or equal
    price: float = Field(gt=0)      # greater than
    quantity: int = Field(ge=0, default=0)
    
    # Collection constraints
    tags: List[str] = Field(min_length=1, max_length=10)
    scores: List[int] = Field(min_length=1, max_length=100)
    
    # Optional with constraint
    nickname: Optional[str] = Field(default=None, max_length=50)

# Validation examples
user = ConstrainedUser(
    name="John Doe",
    username="john_doe",
    email="john@example.com",
    age=25,
    price=19.99,
    tags=["python", "fastapi"]
)

Field Validators

from pydantic import BaseModel, field_validator, Field
import re

class ValidatedUser(BaseModel):
    username: str
    password: str
    age: int
    
    @field_validator('username')
    @classmethod
    def validate_username(cls, v: str) -> str:
        if len(v) < 3:
            raise ValueError('Username must be at least 3 characters')
        if not re.match(r'^[a-zA-Z0-9_]+$', v):
            raise ValueError('Username can only contain letters, numbers, and underscores')
        return v
    
    @field_validator('password')
    @classmethod
    def validate_password(cls, v: str) -> str:
        if len(v) < 8:
            raise ValueError('Password must be at least 8 characters')
        if not re.search(r'[A-Z]', v):
            raise ValueError('Password must contain at least one uppercase letter')
        if not re.search(r'[0-9]', v):
            raise ValueError('Password must contain at least one digit')
        return v
    
    @field_validator('age')
    @classmethod
    def validate_age(cls, v: int) -> int:
        if v < 0 or v > 150:
            raise ValueError('Age must be between 0 and 150')
        return v

# Multiple validators on same field
class MultiValidatedField(BaseModel):
    value: str
    
    @field_validator('value')
    @classmethod
    def strip_value(cls, v: str) -> str:
        return v.strip()  # First: strip whitespace
    
    @field_validator('value')
    @classmethod
    def validate_not_empty(cls, v: str) -> str:
        if not v:
            raise ValueError('Value cannot be empty')
        return v  # Then: check not empty

Model Validators

from pydantic import BaseModel, model_validator, field_validator
from typing import Optional

class UserRegistration(BaseModel):
    password: str
    confirm_password: str
    username: str
    
    @model_validator(mode='before')
    @classmethod
    def check_passwords_match(cls, data):
        """Validate before any field validation."""
        if isinstance(data, dict):
            if data.get('password') != data.get('confirm_password'):
                raise ValueError('Passwords do not match')
        return data
    
    @model_validator(mode='after')
    def validate_username_not_password(self):
        """Validate after all field validation."""
        if self.username.lower() in self.password.lower():
            raise ValueError('Username cannot be part of the password')
        return self

# Root validator (alias for model_validator in v1)
class AdvancedValidation(BaseModel):
    start_date: str
    end_date: str
    
    @model_validator(mode='after')
    def validate_dates(self):
        from datetime import datetime
        start = datetime.fromisoformat(self.start_date)
        end = datetime.fromisoformat(self.end_date)
        
        if end < start:
            raise ValueError('End date must be after start date')
        
        return self

Serialization

Model Dump

from pydantic import BaseModel
from typing import List, Optional

class User(BaseModel):
    id: int
    name: str
    email: str
    tags: List[str] = []
    metadata: Optional[dict] = None

user = User(
    id=1,
    name="John",
    email="john@example.com",
    tags=["admin", "developer"],
    metadata={"department": "Engineering"}
)

# To dictionary
data = user.model_dump()
print(data)
# {'id': 1, 'name': 'John', 'email': 'john@example.com', 'tags': ['admin', 'developer'], 'metadata': {'department': 'Engineering'}}

# To JSON string
json_str = user.model_dump_json()
print(json_str)
# {"id":1,"name":"John",...}

# Include/Exclude fields
data = user.model_dump(include={'id', 'name'})  # Only id and name
data = user.model_dump(exclude={'metadata'})     # Everything except metadata

Advanced Serialization

from pydantic import BaseModel, Field, SerializerFunctionWrap
from typing import List, Optional
from datetime import datetime

class User(BaseModel):
    id: int
    name: str
    created_at: datetime
    
    # Custom serialization
    @property
    def display_name(self) -> str:
        return f"#{self.id} - {self.name}"
    
    # Custom field serializer
    @field_serializer('created_at')
    def serialize_datetime(self, dt: datetime) -> str:
        return dt.isoformat()

user = User(id=1, name="John", created_at=datetime.now())

# Serialization options
data = user.model_dump(
    mode='json',           # Convert to JSON-serializable types
    include={'id', 'name'},
    exclude={'created_at'},
    by_alias=True,         # Use field aliases
    exclude_none=True,    # Exclude None values
    exclude_unset=True,   # Exclude unset fields
    exclude_defaults=True # Exclude default values
)

Model Validate

from pydantic import BaseModel
from typing import Optional

class User(BaseModel):
    id: int
    name: str
    email: str

# From dictionary
data = {"id": 1, "name": "John", "email": "john@example.com"}
user = User.model_validate(data)

# From JSON string
json_str = '{"id": 2, "name": "Jane", "email": "jane@example.com"}'
user = User.model_validate_json(json_str)

# From object
class SomeClass:
    def __init__(self):
        self.id = 3
        self.name = "Bob"
        self.email = "bob@example.com"

obj = SomeClass()
user = User.model_validate(obj)

# Partial update
data = {"name": "Updated Name"}
user = User.model_validate(data, update={"id": 1, "email": "old@example.com"})

JSON Schema

from pydantic import BaseModel

class User(BaseModel):
    id: int
    name: str
    email: str

# Generate JSON Schema
schema = User.model_json_schema()
print(schema)

# With configuration
class ConfiguredUser(BaseModel):
    model_config = {'title': 'User Model', 'description': 'A user model'}
    
    id: int
    name: str

schema = ConfiguredUser.model_json_schema()

Alias and Naming

Field Aliases

from pydantic import BaseModel, Field, AliasChoices

class UserWithAlias(BaseModel):
    # Use alias for input, different name for code
    user_id: int = Field(alias='userId')
    first_name: str = Field(alias='firstName')
    last_name: str = Field(alias='lastName')
    email_address: str = Field(validation_alias='email')  # AliasChoices for multiple

# Populate using alias
data = {"userId": 1, "firstName": "John", "lastName": "Doe", "email": "john@example.com"}
user = UserWithAlias.model_validate(data)

# Access by Python name
print(user.user_id)
print(user.first_name)

# Serialize with alias
json_data = user.model_dump(by_alias=True)
# {'userId': 1, 'firstName': 'John', 'lastName': 'Doe', 'email': 'john@example.com'}

Alias Generator

from pydantic import BaseModel, ConfigDict
from pydantic.alias_generators import to_camel, to_snake, to_pascal

class CamelCaseUser(BaseModel):
    """User with camelCase serialization."""
    model_config = ConfigDict(alias_generator=to_camel)
    
    user_id: int
    first_name: str
    last_name: str
    email_address: str

user = CamelCaseUser(
    user_id=1,
    first_name="John",
    last_name="Doe",
    email_address="john@example.com"
)

# Serialized as camelCase
print(user.model_dump(by_alias=True))
# {'userId': 1, 'firstName': 'John', 'lastName': 'Doe', 'emailAddress': 'john@example.com'}

Default Values

Field Defaults

from pydantic import BaseModel, Field
from typing import Optional, List

class DefaultsExample(BaseModel):
    # Simple default
    name: str = "Unknown"
    
    # Default with type
    age: int = 25
    
    # Optional with default None
    nickname: Optional[str] = None
    
    # Required (no default)
    email: str
    
    # Field with default
    tags: List[str] = Field(default_factory=list)
    
    # Field with factory
    items: List[int] = Field(default_factory=lambda: [1, 2, 3])
    
    # Default factory for mutable objects (important!)
    metadata: dict = Field(default_factory=dict)
    scores: List[int] = Field(default_factory=list)

# Using default_factory for complex defaults
import uuid
class WithFactory(BaseModel):
    id: str = Field(default_factory=lambda: str(uuid.uuid4()))
    created_at: str = Field(default_factory=lambda: "generated_value")

Default Values with Validators

from pydantic import BaseModel, field_validator, Field
from typing import Optional

class UserWithDefaultValidator(BaseModel):
    username: str
    # Validator runs even with default
    display_name: str = Field(default="")
    
    @field_validator('display_name', mode='before')
    @classmethod
    def use_username_as_display_name(cls, v, info):
        # If display_name not provided, use username
        if v == "":
            # info.data contains other field values
            return info.data.get('username', 'Unknown')
        return v

# Without display_name - uses username
user = UserWithDefaultValidator(username="john")
print(user.display_name)  # "john"

# With display_name - uses provided value
user = UserWithDefaultValidator(username="john", display_name="John Doe")
print(user.display_name)  # "John Doe"

Constrained Types

String Constraints

from pydantic import BaseModel, Field, constr

# Using Field
class FieldConstraints(BaseModel):
    username: str = Field(min_length=3, max_length=20)
    password: str = Field(min_length=8)
    slug: str = Field(pattern=r'^[a-z0-9-]+$')

# Using constrained types
class ConstrainedTypes(BaseModel):
    # constr creates a constrained string type
    username: constr(min_length=3, max_length=20)
    password: constr(min_length=8)
    slug: constr(pattern=r'^[a-z0-9-]+$')
    email: constr(pattern=r'^[\w\.-]+@[\w\.-]+\.\w+$')

user = ConstrainedTypes(
    username="john_doe",
    password="secret123",
    slug="my-blog-post",
    email="john@example.com"
)

Numeric Constraints

from pydantic import BaseModel, Field, conint, confloat, conlist

class NumericConstraints(BaseModel):
    # Integer constraints
    quantity: conint(ge=0, le=1000)
    age: conint(ge=0, le=150)
    
    # Float constraints
    price: confloat(gt=0)
    rating: confloat(ge=0.0, le=5.0)
    
    # List constraints
    scores: conlist(int, min_length=1, max_length=10)
    codes: conlist(str, min_length=3)

product = NumericConstraints(
    quantity=10,
    age=25,
    price=19.99,
    rating=4.5,
    scores=[90, 85, 95],
    codes=["ABC", "DEF"]
)

Special Types

Email, URL, UUID

from pydantic import BaseModel, EmailStr, HttpUrl, UUID1, UUID4, PaymentCardNumber
from typing import Optional
import uuid

class ContactInfo(BaseModel):
    # Email validation
    email: EmailStr
    secondary_email: Optional[EmailStr] = None
    
    # URL validation
    website: HttpUrl
    api_endpoint: Optional[HttpUrl] = None
    
    # UUID
    user_uuid: UUID1  # UUID version 1
    session_uuid: UUID4  # UUID version 4
    
    # Payment card (Luhn validation)
    card_number: Optional[PaymentCardNumber] = None

contact = ContactInfo(
    email="user@example.com",
    website="https://example.com",
    user_uuid=uuid.uuid1(),
    session_uuid=uuid.uuid4()
)

Secret Types

from pydantic import BaseModel, SecretStr, SecretBytes

class Credentials(BaseModel):
    # Masks value in output
    password: SecretStr
    api_key: Optional[SecretStr] = None
    
    # For binary secrets
    encryption_key: SecretBytes

creds = Credentials(password="secret123")

# Access the secret
print(creds.password)              # SecretStr('**********')
print(creds.password.get_secret_value())  # "secret123"

# Serialization
data = creds.model_dump()
# {'password': SecretStr('**********'), 'api_key': None, 'encryption_key': SecretBytes(b'**********')}

Enum Types

from pydantic import BaseModel, StrEnum, IntEnum
from enum import Enum

class Status(str, Enum):
    PENDING = "pending"
    ACTIVE = "active"
    COMPLETED = "completed"
    FAILED = "failed"

class Priority(int, Enum):
    LOW = 1
    MEDIUM = 2
    HIGH = 3

class Task(BaseModel):
    # Works with any enum
    status: Status = Status.PENDING
    priority: Priority = Priority.MEDIUM

task = Task(status=Status.ACTIVE, priority=Priority.HIGH)

# StringEnum (Pydantic v2)
class UserRole(StrEnum):
    ADMIN = "admin"
    USER = "user"
    GUEST = "guest"

class User(BaseModel):
    role: UserRole = UserRole.GUEST

Configuration

Model Config

from pydantic import BaseModel, ConfigDict
from typing import Optional

# Pydantic v2 style
class User(BaseModel):
    model_config = ConfigDict(
        str_strip_whitespace=True,      # Strip whitespace from strings
        validate_assignment=True,       # Validate on assignment
        arbitrary_types_allowed=True,   # Allow arbitrary types
        use_enum_values=True,           # Use enum values (not enum objects)
        populate_by_name=True,          # Allow population by name (not alias)
        extra='forbid',                 # Forbid extra fields
        frozen=True,                    # Make model immutable
    )
    
    id: int
    name: str
    email: Optional[str] = None

# v1 style (still works)
class UserV1(BaseModel):
    class Config:
        str_strip_whitespace = True
        validate_assignment = True
    
    id: int
    name: str

Field-Level Config

from pydantic import BaseModel, Field, field_config

@field_config(validate_default=True)
def validated_field():
    return Field(default="default_value")

class ModelWithFieldConfig(BaseModel):
    # This field will be validated even with default
    value: str = Field(default="test", validate_default=True)

Inheritance

Model Inheritance

from pydantic import BaseModel
from typing import Optional

class BaseUser(BaseModel):
    id: int
    name: str
    email: str

class UserWithAge(BaseUser):
    age: Optional[int] = None

class AdminUser(UserWithAge):
    is_admin: bool = True
    permissions: list[str] = []

# Inheritance works
admin = AdminUser(
    id=1,
    name="John",
    email="john@example.com",
    age=30,
    is_admin=True,
    permissions=["read", "write"]
)

Composition

from pydantic import BaseModel
from typing import Optional

class TimestampMixin(BaseModel):
    """Mixin to add timestamps."""
    created_at: str
    updated_at: str

class UserMixin(BaseModel):
    """Mixin to add user fields."""
    name: str
    email: str

class User(TimestampMixin, UserMixin):
    id: int

user = User(
    id=1,
    name="John",
    email="john@example.com",
    created_at="2024-01-01",
    updated_at="2024-01-01"
)

Generic Models

Generic Models

from pydantic import BaseModel
from typing import Generic, TypeVar, List

T = TypeVar('T')

class Container(BaseModel, Generic[T]):
    """Generic container model."""
    items: List[T] = []
    total: int = 0

class StringContainer(Container[str]):
    pass

class IntContainer(Container[int]):
    pass

# Usage
string_container = StringContainer(items=["a", "b", "c"], total=3)
int_container = IntContainer(items=[1, 2, 3], total=3)

# With bounds
class OrderedModel(BaseModel, Generic[T]):
    item: T
    order: int

class StringOrdered(OrderedModel[str]):
    pass

Nested Generics

from pydantic import BaseModel
from typing import Generic, TypeVar, Optional

K = TypeVar('K')
V = TypeVar('V')

class KeyValue(BaseModel, Generic[K, V]):
    key: K
    value: V

class DataStore(BaseModel, Generic[K, V]):
    items: List[KeyValue[K, V]] = []
    metadata: Optional[dict] = None

# Usage
store = DataStore[str, int](
    items=[
        KeyValue(key="age", value=25),
        KeyValue(key="count", value=10)
    ],
    metadata={"source": "database"}
)

Discriminated Unions

Basic Discriminated Union

from pydantic import BaseModel, Tag
from typing import Union

class Cat(BaseModel):
    pet_type: str = "cat"
    meows: int

class Dog(BaseModel):
    pet_type: str = "dog"
    barks: float

class Zoo(BaseModel):
    # Using Tag for discriminated union
    pet: Union[Cat, Dog] = Field(..., discriminator='pet_type')

# Pydantic v2 style
from pydantic import Discriminator

class Zoo(BaseModel):
    pet: Annotated[
        Union[Cat, Dog],
        Discriminator(tag='pet_type')
    ]

Advanced Discriminated Union

from pydantic import BaseModel
from typing import Union, Literal

class Image(BaseModel):
    type: Literal["image"]
    url: str

class Video(BaseModel):
    type: Literal["video"]
    url: str
    duration: int

class Document(BaseModel):
    type: Literal["document"]
    pages: int

Media = Union[Image, Video, Document]

class Post(BaseModel):
    title: str
    media: Media

# Pydantic automatically routes based on discriminator
post = Post(
    title="My Post",
    media={"type": "video", "url": "https://example.com/video.mp4", "duration": 120}
)

Validation Errors

Handling Errors

from pydantic import BaseModel, ValidationError

class User(BaseModel):
    id: int
    name: str
    age: int

try:
    user = User(id="not an int", name="John", age="not an int")
except ValidationError as e:
    # Error details
    print(e.errors())
    # [
    #     {
    #         'type': 'int_parsing',
    #         'loc': ('id',),
    #         'msg': 'Input should be a valid integer',
    #         'input': 'not an int'
    #     },
    #     {
    #         'type': 'int_parsing',
    #         'loc': ('age',),
    #         'msg': 'Input should be a valid integer',
    #         'input': 'not an int'
    #     }
    # ]
    
    # Human-readable
    print(e)
    # 2 validation errors for User
    # id
    #   Input should be a valid integer [type=int_parsing, input_value='not an int', input_type=str]
    # age
    #   Input should be a valid integer [type=int_parsing, input_value='not an int', input_type=str]

Custom Error Messages

from pydantic import BaseModel, field_validator, ValidationError

class User(BaseModel):
    age: int
    
    @field_validator('age')
    @classmethod
    def validate_age(cls, v):
        if v < 0:
            raise ValueError('Age must be a positive number')
        if v > 150:
            raise ValueError('Are you really that old?')
        return v

try:
    User(age=-5)
except ValidationError as e:
    print(e.error_count())  # 1
    for error in e.errors():
        print(f"Field: {error['loc']}")
        print(f"Message: {error['msg']}")
        print(f"Input: {error['input']}")

Settings

BaseSettings

from pydantic_settings import BaseSettings, SettingsConfigDict
from typing import Optional

class Settings(BaseSettings):
    """Application settings from environment variables."""
    
    # Required settings
    app_name: str
    database_url: str
    
    # Optional with defaults
    debug: bool = False
    port: int = 8000
    max_connections: int = 10
    
    # Sensitive settings (will be masked in output)
    secret_key: str
    
    # Settings with aliases
    db_host: str = Field(alias='DATABASE_HOST', default='localhost')
    
    #model_config = SettingsConfigDict(env_file='.env', env_file_encoding='utf-8')

# Usage
# Reads from environment variables and .env file
settings = Settings(
    app_name="My App",
    database_url="postgresql://localhost/mydb",
    secret_key="super-secret"
)

# Access values
print(settings.app_name)
print(settings.debug)

# Configuration via model_config
class SettingsV2(BaseSettings):
    model_config = SettingsConfigDict(
        env_file='.env',
        env_file_encoding='utf-8',
        case_sensitive=False,
        extra='ignore'
    )
    
    app_name: str
    debug: bool = False

Nested Settings

from pydantic_settings import BaseSettings, SettingsConfigDict
from typing import Optional

class DatabaseSettings(BaseSettings):
    """Database configuration."""
    host: str = "localhost"
    port: int = 5432
    name: str
    user: str
    password: str

class RedisSettings(BaseSettings):
    """Redis configuration."""
    host: str = "localhost"
    port: int = 6379
    db: int = 0

class Settings(BaseSettings):
    """Application settings."""
    app_name: str
    database: DatabaseSettings
    redis: RedisSettings
    
    model_config = SettingsConfigDict(env_nested_delimiter='__')

# Environment variables:
# APP_NAME=MyApp
# DATABASE__HOST=db.example.com
# DATABASE__PORT=5432
# REDIS__HOST=redis.example.com

settings = Settings(
    app_name="MyApp",
    database={"name": "mydb", "user": "user", "password": "pass"},
    redis={}
)

FastAPI Integration

Request Models

from fastapi import FastAPI, Depends, HTTPException
from pydantic import BaseModel, EmailStr, Field

app = FastAPI()

class UserCreate(BaseModel):
    """User creation schema."""
    username: str = Field(min_length=3, max_length=50)
    email: EmailStr
    password: str = Field(min_length=8)
    age: int = Field(ge=0, le=150)

class UserResponse(BaseModel):
    """User response schema (excludes sensitive data)."""
    id: int
    username: str
    email: str
    
    model_config = {'from_attributes': True}

@app.post("/users", response_model=UserResponse, status_code=201)
async def create_user(user: UserCreate):
    """Create a new user."""
    # Validate and process user data
    # user is already validated!
    new_user = await save_user(user.model_dump())
    return new_user

@app.get("/users/{user_id}", response_model=UserResponse)
async def get_user(user_id: int):
    """Get user by ID."""
    user = await get_user_by_id(user_id)
    if not user:
        raise HTTPException(status_code=404, detail="User not found")
    return user

Response Models

from fastapi import FastAPI
from pydantic import BaseModel
from datetime import datetime

class Item(BaseModel):
    id: int
    name: str
    price: float

class ItemWithTax(BaseModel):
    """Item with calculated tax."""
    id: int
    name: str
    price: float
    tax: float
    
    @classmethod
    def from_item(cls, item: Item):
        return cls(
            id=item.id,
            name=item.name,
            price=item.price,
            tax=item.price * 0.1  # 10% tax
        )

app = FastAPI()

@app.get("/items/{item_id}", response_model=ItemWithTax)
async def get_item(item_id: int):
    item = await get_item_from_db(item_id)
    return ItemWithTax.from_item(item)

Nested Validation

from fastapi import FastAPI
from pydantic import BaseModel, ValidationError

app = FastAPI()

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

class UserWithAddress(BaseModel):
    name: str
    address: Address

@app.post("/users")
async def create_user(user: UserWithAddress):
    return user

# Nested validation error example:
# Request: {"name": "John", "address": {"street": "123 Main"}}
# Error: Validation error for address.city (field required)

Best Practices

1. Use Type Hints

# Good: Full type hints
class User(BaseModel):
    id: int
    name: str
    email: str
    is_active: bool = True

# Bad: Missing type hints
class User(BaseModel):
    id = None
    name = None

2. Define Defaults Properly

# Good: Use default_factory for mutable objects
class User(BaseModel):
    tags: List[str] = Field(default_factory=list)
    metadata: dict = Field(default_factory=dict)

# Bad: Mutable default argument
class User(BaseModel):
    tags: List[str] = []  # ERROR: mutable default!
    metadata: dict = {}

3. Use Constrained Types

# Good: Constrained types
class User(BaseModel):
    username: str = Field(min_length=3, max_length=20)
    age: int = Field(ge=0, le=150)

# Bad: Unconstrained validation in handler
class User(BaseModel):
    username: str
    age: int
    
    @field_validator('username')
    def validate_username(self, v):
        if len(v) < 3 or len(v) > 20:
            raise ValueError('Invalid username')
        return v

4. Separate Schemas

# Good: Separate schemas for different operations
class UserBase(BaseModel):
    email: EmailStr

class UserCreate(UserBase):
    username: str
    password: str

class UserUpdate(BaseModel):
    email: Optional[EmailStr] = None
    username: Optional[str] = None

class UserResponse(UserBase):
    id: int
    created_at: datetime
    
    model_config = {'from_attributes': True}

Common Issues

Performance

# Issue: Slow validation
# Solution: Use Pydantic v2 (much faster)
# pip install pydantic>=2.0.0

# Solution: Use orjson for serialization
# pip install orjson

import orjson

class FastModel(BaseModel):
    model_config = {'json_loads': orjson.loads, 'json_dumps': orjson.dumps}

Mutable Defaults

# Issue: Mutable default arguments
# Error in Pydantic v2
class BadModel(BaseModel):
    items: list = []  # ERROR!

# Solution: Use default_factory
class GoodModel(BaseModel):
    items: list = Field(default_factory=list)

Validation Order

# Issue: Validator order
# In Pydantic v2, field_validators run in order of definition
# mode='before' validators run first, then field type validation, then 'after'

class OrderedValidation(BaseModel):
    value: str
    
    @field_validator('value', mode='before')
    @classmethod
    def before_validation(cls, v):
        # Runs first
        return v.strip().lower() if isinstance(v, str) else v
    
    @field_validator('value')
    @classmethod
    def after_validation(cls, v):
        # Runs after type validation
        return v

References