Skip to content
Back to top
View this page

# Pydantic V2 Cheat Sheet

This guide consolidates strict typing, date handling, and validation into reusable patterns.

## The Pydantic V2 Lifecycle

Understanding when validation and serialization run is key to using the tools below.

## 1. Shared Base Model

Use a project base model when schemas share strict input hygiene and model configuration.

    from pydantic import BaseModel, ConfigDict

    class AppBaseModel(BaseModel):
        model_config = ConfigDict(
            # Strip leading/trailing whitespace automatically ("  val  " -> "val").
            str_strip_whitespace=True,
            # Reject extra fields not defined in the model.
            extra='forbid',
            # Validate default values.
            validate_default=True,
            # Allow population by alias, such as accepting "camelCase" input.
            populate_by_name=True,
        )

## 2. DateTime & Timezones (Strict Mode)

Never use naive datetimes. Always use `AwareDatetime` and `default_factory`.

    from datetime import datetime, timezone
    from pydantic import Field, AwareDatetime

    class TimestampModel(AppBaseModel):
        # Enforces timezone metadata in input.
        event_time: AwareDatetime

        # Dynamic default calculated at runtime and stored as UTC.
        created_at: AwareDatetime = Field(
            default_factory=lambda: datetime.now(timezone.utc)
        )

        # Bad: static default calculated at server startup.
        # created_at: datetime = datetime.now()

## 3. Reusable Fields (Mixins)

Use mixins for fields that appear across multiple models, such as localized `name_de`/`name_en` labels.

    from typing import Optional
    from pydantic import BaseModel, model_validator

    class LocalizedNameMixin(BaseModel):
        name: str
        name_de: Optional[str] = None
        name_en: Optional[str] = None

        @model_validator(mode='after')
        def fallback_translations(self) -> 'LocalizedNameMixin':
            """Autofill missing translations with the primary name."""
            if not self.name_en:
                self.name_en = self.name
            if not self.name_de:
                self.name_de = self.name
            return self

    # Usage
    class Product(LocalizedNameMixin, AppBaseModel):
        sku: str
        price: float

## 4. Advanced Validation Patterns

### A. Sorting & Uniqueness (Lists)

Enforce that a list is unique and sorted upon creation.

    from typing import List
    from pydantic import field_validator

    class TaggedItem(AppBaseModel):
        tags: List[str]

        @field_validator('tags')
        @classmethod
        def validate_tags(cls, v: List[str]) -> List[str]:
            # 1. Deduplicate (set)
            # 2. Sort (sorted)
            # 3. Return (must return the value)
            return sorted(list(set(v)))

### B. Dictionary Storage With List API Payloads

Store data as a Dict (for O(1) performance), but accept and return Lists (for API standards).

    from typing import Dict, List, Any
    from pydantic import BaseModel, field_validator, field_serializer, ValidationInfo

    class Inventory(BaseModel):
        # Internal storage is a Dict
        items: Dict[str, Any]

        # 1. INPUT: Accept a List, convert to Dict
        @field_validator('items', mode='before')
        @classmethod
        def parse_list_to_dict(cls, v: Any) -> Dict[str, Any]:
            if isinstance(v, list):
                # Assumes items have a 'name' key; converts list to dict
                return {item['name']: item for item in v}
            return v

        # 2. OUTPUT: Convert Dict back to List for JSON
        @field_serializer('items')
        def serialize_dict_to_list(self, v: Dict[str, Any], _info) -> List[Any]:
            return list(v.values())

## 5. Computed Fields (Derived Data)

Use computed fields for values that should appear in the API response but should not be persisted directly.

    from pydantic import computed_field

    class Rectangle(AppBaseModel):
        width: int
        height: int

        @computed_field
        def area(self) -> int:
            return self.width * self.height

    # JSON Output: { "width": 10, "height": 5, "area": 50 }

## 6. Managing Aliases (Frontend vs Backend)

Handle `camelCase` (JS frontend) vs `snake_case` (Python backend).

    from pydantic import Field

    class User(AppBaseModel):
        # Python sees: first_name
        # JSON input/output can be: firstName
        first_name: str = Field(alias="firstName")

    # Usage
    # User(firstName="John") -> sets user.first_name to "John"

## 7. Path Objects & Filesystems

Prefer `pathlib` types over raw strings whenever a model touches the filesystem. This preserves cross-platform semantics and lets Pydantic validate early.

    from pathlib import Path
    from pydantic import BaseModel, FilePath, DirectoryPath, ConfigDict, field_validator

    class FileInput(BaseModel):
        model_config = ConfigDict(str_strip_whitespace=True)

        # Use the specialized validators for existing files/dirs.
        template_path: FilePath
        output_dir: DirectoryPath

        # Accept strings, but normalize to absolute Paths.
        @field_validator('template_path', 'output_dir', mode='after')
        @classmethod
        def resolve_paths(cls, value: Path) -> Path:
            return value.expanduser().resolve()

    class LazyPathModel(BaseModel):
        # Use plain Path when the resource might not exist yet.
        export_path: Path = Path('exports/report.json')

        @field_validator('export_path', mode='before')
        @classmethod
        def default_suffix(cls, value: str | Path) -> Path:
            """Ensure the path carries the correct extension."""
            path = Path(value)
            return path if path.suffix else path.with_suffix('.json')

Best practices:

- Normalize inputs with `expanduser()`/`resolve()` so downstream services never see `~` or relative segments.

- Use `FilePath`/`DirectoryPath` when the path must already exist; use plain `Path` for lazily created artifacts.

- Keep path defaults inside `Path` objects (not strings) to avoid OS-specific separators.

- Add validators when business rules apply (extensions, allowed roots, etc.) and document the behavior in docstrings.

## 8. YAML Fixtures & Sample Data

Treat YAML fixtures like immutable contracts: load them through your models so drift is caught immediately.

    import yaml
    from pathlib import Path
    from typing import Iterable

    def load_fixtures(path: Path, model) -> Iterable:
        raw = yaml.safe_load(path.read_text())
        for item in raw:
            yield model.model_validate(item)

    # Usage
    # for person in load_fixtures(Path('data/sample_people.yaml'), PersonModel):
    #     ...

Best practices:

- Co-locate fixtures next to the consuming models/tests (e.g. `tests/fixtures/*.yaml`) and treat them as versioned assets.

- Always validate after loading (`model_validate`) so schema changes fail fast instead of producing silent runtime bugs.

- Keep human-friendly anchors/comments, but avoid executable YAML features (tags, !!python) for security.

- Store metadata (`version`, `generated_at`, `source`) at the document root to help migrations.

- Split sensitive overrides into separate files (`fixture.local.yaml`) and load them explicitly to avoid leaking secrets.

- Wire fixtures into unit tests to guarantee they stay in sync with production schemas.

## Summary Table: Validator Modes

Decorator | Mode | Use Case
--- | --- | ---
`@field_validator` | `after` (default) | Validating business logic (e.g., “age > 18”). Data is already Python types.
`@field_validator` | `before` | Pre-processing raw data (e.g., parsing a comma-separated string into a list).
`@model_validator` | `after` | Multi-field logic (e.g., “start_date must be before end_date”).
`@model_validator` | `before` | Reshaping the entire incoming JSON structure before Pydantic touches it.