Skip to content
Back to skills

Pydantic Patterns

ASecurity

Enforces Pydantic patterns with DDD and Clean Architecture: Value Objects, request/response schemas, from_domain() mapping, TypeAdapter, settings management. Activates when working with data models, validation, or configuration.

  • 5 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added May 28, 2026
developmentpythonrustgobashsqlfastapidockerkubernetesapidatabase

Works with

  • cli
  • api

Security analysis

A100/100

Scanned May 28, 2026

npx -y skills add AppVerk/av-marketplace --skill pydantic-patterns --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Pydantic Patterns?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Pydantic Patterns
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/appverk-pydantic-patterns/badge)](https://www.skillsdirectory.com/skills/appverk-pydantic-patterns)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: pydantic-patterns
description: Enforces Pydantic patterns with DDD and Clean Architecture: Value Objects, request/response schemas, from_domain() mapping, TypeAdapter, settings management. Activates when working with data models, validation, or configuration.
allowed-tools: Read, Grep, Glob, Bash(ruff:*), Bash(mypy:*), Bash(basedpyright:*), Bash(make:*), Bash(uv:*), Bash(python:*), Bash(pytest:*)
---

# Pydantic Patterns (DDD / Clean Architecture)

<HARD-RULES>
These rules are NON-NEGOTIABLE. Violating any of them is a bug.

**Architecture & Layers:**
- NEVER use `BaseModel` for domain entities — ALWAYS use `dataclass` for mutable entities; `BaseModel(frozen=True)` is only for Value Objects that need validation
- NEVER return domain entities directly from API endpoints — ALWAYS map through response schemas with `from_domain()` classmethod
- NEVER expose ORM models as API responses — ALWAYS use dedicated Pydantic schemas in the presentation layer

**Model Design:**
- NEVER use Pydantic v1 patterns (`validator`, `root_validator`, `schema_extra`, `orm_mode`, `.dict()`, `.json()`) — ALWAYS use v2 API
- NEVER use `Optional[X]` in model fields — ALWAYS use `X | None`
- NEVER use mutable default values directly in field definitions — use `default_factory`
- NEVER use `Any` as a field type — define explicit types or union types
- NEVER use `model_validate` with `from_attributes=True` unless `model_config = ConfigDict(from_attributes=True)` is set
- ALWAYS use `Field()` with constraints instead of writing manual validators for simple checks
- ALWAYS use `Annotated` types for reusable validation logic
- ALWAYS separate Create/Update/Response schemas — NEVER use one model for all operations

**Types & Generics:**
- ALWAYS use PEP 695 type parameter syntax (`class Foo[T]`, `type Alias = ...`) — NEVER use `TypeVar` / `Generic[T]` / `TypeAlias`

**Settings:**
- ALWAYS use `BaseSettings` from `pydantic-settings` for configuration — NEVER parse env vars manually
- ALWAYS use `SecretStr` for sensitive settings fields (passwords, API keys, tokens) — NEVER use plain `str`

**Quality:**
- ALWAYS run the project's typecheck and test commands after any schema change
</HARD-RULES>

These are the Pydantic v2 patterns for AppVerk projects, following **Domain-Driven Design** and **Clean Architecture**. All patterns target **Python 3.13+** and **Pydantic v2.7+**.

## Architecture Overview

```
┌──────────────────────────────────────────────────────┐
│  Presentation Layer (FastAPI routers, schemas)        │
│  Pydantic: Request schemas (Create/Update),          │
│    Response schemas (from_domain()), Query models     │
│  Depends on: Application Layer, Domain Layer         │
├──────────────────────────────────────────────────────┤
│  Application Layer (Services / Use Cases)            │
│  Pydantic: NOT used (services work with domain       │
│    entities and Value Objects directly)               │
├──────────────────────────────────────────────────────┤
│  Domain Layer (Entities, Value Objects)               │
│  Pydantic: Value Objects only (frozen=True).         │
│  Entities are pure dataclasses.                       │
│  Depends on: NOTHING                                 │
├──────────────────────────────────────────────────────┤
│  Infrastructure Layer (ORM, Settings, Adapters)       │
│  Pydantic: Settings (BaseSettings), TypeAdapter for  │
│    boundary validation, from_attributes for ORM.     │
│  Depends on: Domain Layer                             │
└──────────────────────────────────────────────────────┘
```

**Pydantic's role per layer:**
- **Domain**: Value Objects with `frozen=True` when validation is needed. Entities are **not** BaseModel — they are mutable dataclasses (see `sqlalchemy-patterns` skill).
- **Presentation**: Request schemas (`*Create`, `*Update`), response schemas (`*Response` with `from_domain()`), query parameter models.
- **Infrastructure**: `BaseSettings` for configuration, `from_attributes=True` for ORM mapping, `TypeAdapter` for boundary validation.
- **Application**: Pydantic is **not used** in this layer. Services receive domain entities and Value Objects, not Pydantic models.

## Domain Layer — Value Objects

Value Objects have **no identity** and are **immutable**. Use `BaseModel(frozen=True)` when you need Pydantic's validation, or `@dataclass(frozen=True)` for simple cases.

### Value Object with Validation

```python
# app/domain/value_objects/money.py
from decimal import Decimal

from pydantic import BaseModel, ConfigDict, field_validator


class Money(BaseModel):
    """Immutable Value Object — equality by value."""

    model_config = ConfigDict(frozen=True)

    amount: Decimal
    currency: str = "PLN"

    @field_validator("currency")
    @classmethod
    def validate_currency(cls, v: str) -> str:
        if v not in ("USD", "EUR", "GBP", "PLN"):
            raise ValueError(f"Unsupported currency: {v}")
        return v.upper()

    def add(self, other: "Money") -> "Money":
        if self.currency != other.currency:
            raise ValueError("Cannot add different currencies")
        return self.model_copy(update={"amount": self.amount + other.amount})

    def subtract(self, other: "Money") -> "Money":
        if self.currency != other.currency:
            raise ValueError("Cannot subtract different currencies")
        return self.model_copy(update={"amount": self.amount - other.amount})
```

### Value Object with Enum

```python
# app/domain/value_objects/market_status.py
from enum import StrEnum


class MarketStatus(StrEnum):
    DRAFT = "draft"
    ACTIVE = "active"
    CLOSED = "closed"
    EXPIRED = "expired"
```

### Single-Field Value Object (RootModel)

For value types wrapping a single primitive, use `RootModel`:

```python
from pydantic import ConfigDict, RootModel, field_validator


class Email(RootModel[str]):
    model_config = ConfigDict(frozen=True)

    @field_validator("root")
    @classmethod
    def validate_email(cls, v: str) -> str:
        if "@" not in v:
            raise ValueError("Invalid email format")
        return v.strip().lower()


# Usage:
email = Email("USER@example.com")
print(email.root)          # "user@example.com"
print(email.model_dump())  # "user@example.com" (not {"root": ...})
```

### Value Object Conventions

- **Always `frozen=True`** — immutability is the defining property.
- **Use `model_copy(update=...)`** instead of creating new instances manually — preserves validation.
- **Equality by value** — Pydantic `frozen=True` models support `==` and hashing by default.
- **No identity** — Value Objects never have an `id` field.
- **Self-contained validation** — all constraints live inside the Value Object.

## Domain Layer — Entity Guidance

Domain entities are **pure Python dataclasses**, not Pydantic `BaseModel`. This is consistent with the `sqlalchemy-patterns` skill.

**Why dataclasses for entities:**
- Entities are **mutable** — `BaseModel` encourages immutability, `dataclass` is naturally mutable.
- Entities represent **trusted internal state** — no need to re-validate on every access. Validate at boundaries only.
- **Performance** — dataclass instantiation is ~6.5x faster than `BaseModel`.
- **No external dependencies** — domain layer has zero dependencies.

```python
# app/domain/entities/market.py
from dataclasses import dataclass, field
from datetime import datetime
from uuid import UUID

from app.domain.value_objects.market_status import MarketStatus


@dataclass
class Market:
    id: UUID
    name: str
    description: str
    slug: str
    status: MarketStatus
    end_date: datetime
    created_by_id: UUID
    created_at: datetime | None = None
    updated_at: datetime | None = None
    outcomes: list["Outcome"] = field(default_factory=list)
```

See the `sqlalchemy-patterns` skill for full domain entity patterns.

## Presentation Layer — Request/Response Schemas

### Request Schemas (Create / Update)

```python
# app/schemas/market.py
from datetime import datetime
from uuid import UUID

from pydantic import BaseModel, ConfigDict, Field

from app.domain.entities.market import Market


class MarketCreate(BaseModel):
    """Input schema for creating a market."""

    name: str = Field(min_length=1, max_length=255)
    description: str = Field(min_length=1, max_length=5000)
    end_date: datetime
    min_stake: int = Field(gt=0, le=1_000_000)
    tags: list[str] = Field(default_factory=list, max_length=20)


class MarketUpdate(BaseModel):
    """Input schema for partial market update. All fields optional."""

    name: str | None = Field(default=None, min_length=1, max_length=255)
    description: str | None = Field(default=None, max_length=5000)
    end_date: datetime | None = None
    min_stake: int | None = Field(default=None, gt=0)
```

### Response Schemas with from_domain()

Response schemas map **from domain entities**, not ORM models. The `from_domain()` classmethod documents this intent explicitly.

```python
class MarketResponse(BaseModel):
    """Output schema returned to clients."""

    model_config = ConfigDict(from_attributes=True)

    id: UUID
    name: str
    description: str
    slug: str
    status: str
    min_stake: int
    end_date: datetime
    created_at: datetime
    updated_at: datetime

    @classmethod
    def from_domain(cls, entity: Market) -> "MarketResponse":
        """Map a domain entity to a response schema."""
        return cls.model_validate(entity, from_attributes=True)
```

**How `from_attributes=True` works here:**
- Works with `dataclass` entities (accesses `.name`, `.slug`, etc. as attributes).
- Works with ORM models too (if you ever need it at the infrastructure boundary).
- The `from_domain()` classmethod makes the mapping direction explicit — response schemas map from domain entities, not the other way around.

### Schema Naming Conventions

- `<Entity>Create` — required fields for creation. Use `Field()` constraints.
- `<Entity>Update` — all fields optional for partial updates. Use `exclude_unset=True` in `model_dump()` to detect what was actually sent.
- `<Entity>Response` — full representation returned to clients. Always has `from_domain()` classmethod.

### Nested Models and Composition

Compose models by embedding other models as field types. Never flatten nested data into the parent.

```python
class Address(BaseModel):
    street: str = Field(min_length=1)
    city: str = Field(min_length=1)
    country: str = Field(min_length=2, max_length=2)
    postal_code: str


class CompanyCreate(BaseModel):
    name: str = Field(min_length=1, max_length=255)
    billing_address: Address
    shipping_address: Address | None = None
```

## Generic Models — PEP 695

Use PEP 695 type parameter syntax for all generic Pydantic models. Never use `TypeVar` / `Generic[T]`.

### Paginated Response

```python
# app/schemas/common.py
from pydantic import BaseModel


class PaginatedResponse[T](BaseModel):
    data: list[T]
    total: int
    limit: int
    offset: int
```

### Type Aliases

```python
# PEP 695 type alias syntax
type NonEmptyStr = Annotated[str, StringConstraints(min_length=1, strip_whitespace=True)]
type CentsAmount = Annotated[int, Field(gt=0, le=100_000_000)]
```

### PEP 695 Caveats

PEP 695 generics work well for simple generic models (the most common case). Known limitations:
- Multi-level inheritance with `__init_subclass__` may fail — fall back to `Generic[T]` for these rare cases.
- `ClassVar` types are incompatible with PEP 695 syntax.
- mypy plugin may incorrectly infer variance on `frozen=True` generics.

## Validators

### Field Validators

Use `@field_validator` for single-field validation. Always use `@classmethod` and declare the mode explicitly.

```python
from pydantic import BaseModel, field_validator


class UserCreate(BaseModel):
    username: str
    email: str

    @field_validator("username")
    @classmethod
    def username_must_be_alphanumeric(cls, v: str) -> str:
        if not v.isalnum():
            raise ValueError("Username must be alphanumeric")
        return v.lower()

    @field_validator("email", mode="before")
    @classmethod
    def normalize_email(cls, v: str) -> str:
        if isinstance(v, str):
            return v.strip().lower()
        return v
```

### Model Validators

Use `@model_validator` for cross-field validation. `mode="after"` receives the constructed model; `mode="before"` receives raw input.

```python
from pydantic import BaseModel, model_validator


class DateRange(BaseModel):
    start_date: datetime
    end_date: datetime

    @model_validator(mode="after")
    def end_after_start(self) -> "DateRange":
        if self.end_date <= self.start_date:
            raise ValueError("end_date must be after start_date")
        return self
```

### Annotated Validators (Reusable)

Use `BeforeValidator` and `AfterValidator` with `Annotated` for composable, reusable validation logic.

```python
from typing import Annotated

from pydantic import AfterValidator, BaseModel, BeforeValidator


def normalize_whitespace(v: str) -> str:
    return " ".join(v.split())


def must_be_title_case(v: str) -> str:
    if v != v.title():
        raise ValueError(f"Must be title case: {v!r}")
    return v


CleanTitle = Annotated[
    str,
    BeforeValidator(normalize_whitespace),
    AfterValidator(must_be_title_case),
]


class BookCreate(BaseModel):
    title: CleanTitle
    author: CleanTitle
```

### Validation Context

Pass dynamic data through validation using `ValidationInfo.context`:

```python
from pydantic import BaseModel, ValidationInfo, field_validator


class TransferCreate(BaseModel):
    amount: int
    currency: str

    @field_validator("currency")
    @classmethod
    def currency_must_be_supported(cls, v: str, info: ValidationInfo) -> str:
        supported = info.context.get("supported_currencies", []) if info.context else []
        if supported and v not in supported:
            raise ValueError(f"Currency {v} not supported")
        return v


# Usage:
transfer = TransferCreate.model_validate(
    {"amount": 100, "currency": "USD"},
    context={"supported_currencies": ["USD", "EUR", "PLN"]},
)
```

## Serialization

### model_dump and model_dump_json

```python
user = UserResponse.from_domain(user_entity)

# Dict with exclusions
user.model_dump(exclude={"internal_note"})

# JSON string
user.model_dump_json(indent=2, exclude_none=True)

# Only fields explicitly set (for PATCH endpoints)
update_data = body.model_dump(exclude_unset=True)

# JSON-serializable dict (datetimes become strings, etc.)
user.model_dump(mode="json")
```

### Field-Level Serializers

```python
from datetime import datetime
from decimal import Decimal

from pydantic import BaseModel, field_serializer


class TransactionResponse(BaseModel):
    amount: Decimal
    created_at: datetime

    @field_serializer("amount")
    def serialize_amount(self, amount: Decimal) -> str:
        return f"{amount:.2f}"

    @field_serializer("created_at")
    def serialize_datetime(self, dt: datetime) -> str:
        return dt.isoformat()
```

### Reusable Serializers with Annotated

```python
from typing import Annotated

from pydantic import PlainSerializer, WrapSerializer
from pydantic import SerializerFunctionWrapHandler

# PlainSerializer — replaces default serialization entirely
FormattedDecimal = Annotated[
    Decimal,
    PlainSerializer(lambda x: f"{x:.2f}", return_type=str),
]

# WrapSerializer — modifies default serialization output
def round_float(v: float, handler: SerializerFunctionWrapHandler) -> float:
    return round(handler(v), 2)

RoundedFloat = Annotated[float, WrapSerializer(round_float)]
```

**When to use which:**
- `PlainSerializer` — completely replace serialization (e.g., Decimal → formatted string).
- `WrapSerializer` — tweak the default output (e.g., round floats after normal serialization).
- `@field_serializer` — when the logic is specific to one model, not reusable.

### Computed Fields

Use `@computed_field` for derived values that should appear in serialization.

```python
from pydantic import BaseModel, computed_field


class Rectangle(BaseModel):
    width: float
    height: float

    @computed_field
    @property
    def area(self) -> float:
        return self.width * self.height
```

## Custom Types & TypeAdapter

### Annotated Types for Reusable Validation

Define domain-specific types with `Annotated` to enforce constraints consistently across models.

```python
from typing import Annotated

from pydantic import AfterValidator, Field, StringConstraints

# Constrained string type
type NonEmptyStr = Annotated[str, StringConstraints(min_length=1, strip_whitespace=True)]

# Email normalization
type NormalizedEmail = Annotated[
    str,
    AfterValidator(lambda v: v.strip().lower()),
    StringConstraints(pattern=r"^[^@]+@[^@]+\.[^@]+$"),
]

# Positive money amount in cents
type CentsAmount = Annotated[int, Field(gt=0, le=100_000_000)]

# Slug format
type Slug = Annotated[
    str,
    StringConstraints(pattern=r"^[a-z0-9]+(?:-[a-z0-9]+)*$", min_length=1, max_length=255),
]
```

### TypeAdapter

`TypeAdapter` validates and serializes arbitrary types without needing a `BaseModel`. Use it at boundaries to validate domain types (dataclasses, primitives, unions).

```python
from pydantic import TypeAdapter

from app.domain.entities.market import Market

# Create once, reuse — NEVER create TypeAdapter instances inside hot loops
_market_list_adapter = TypeAdapter(list[Market])


def validate_markets(data: list[dict]) -> list[Market]:
    return _market_list_adapter.validate_python(data)


def serialize_markets(markets: list[Market]) -> str:
    return _market_list_adapter.dump_json(markets).decode()
```

**TypeAdapter use cases:**
- Validating domain dataclasses at API boundaries.
- Generating JSON Schema for non-BaseModel types: `_market_list_adapter.json_schema()`.
- Serializing lists, unions, or primitives without wrapping in a model.

### FailFast Annotation

For large list validation, use `FailFast` to stop at the first invalid item instead of validating all:

```python
from typing import Annotated

from pydantic import BaseModel, FailFast


class BulkImport(BaseModel):
    items: Annotated[list[MarketCreate], FailFast()]
```

### JSON Schema Customization

Use `json_schema_extra` for OpenAPI examples and custom schema extensions:

```python
from pydantic import BaseModel, Field


class MarketCreate(BaseModel):
    model_config = ConfigDict(
        json_schema_extra={
            "examples": [
                {
                    "name": "Election 2024",
                    "description": "US Presidential Election",
                    "end_date": "2024-11-05T00:00:00Z",
                }
            ]
        }
    )

    name: str = Field(min_length=1, max_length=255)
    description: str = Field(min_length=1, max_length=5000)
    end_date: datetime
```

## Discriminated Unions

Use `Field(discriminator=...)` to select the correct model variant based on a type field. Discriminated unions are Rust-powered and significantly faster than untagged unions.

### Field-Level Discriminator

```python
from typing import Literal

from pydantic import BaseModel, Field


class EmailNotification(BaseModel):
    type: Literal["email"]
    recipient: str
    subject: str
    body: str


class SmsNotification(BaseModel):
    type: Literal["sms"]
    phone_number: str
    message: str


class PushNotification(BaseModel):
    type: Literal["push"]
    device_token: str
    title: str
    body: str


class NotificationRequest(BaseModel):
    notification: EmailNotification | SmsNotification | PushNotification = Field(
        discriminator="type"
    )
```

### Callable Discriminator

For complex dispatch logic where the discriminator isn't a simple field value:

```python
from typing import Annotated, Any

from pydantic import BaseModel, Discriminator, Tag


def event_discriminator(v: Any) -> str:
    if isinstance(v, dict):
        if "order_id" in v:
            return "order"
        if "user_id" in v:
            return "user"
    return "unknown"


class OrderEvent(BaseModel):
    order_id: str
    action: str


class UserEvent(BaseModel):
    user_id: str
    action: str


class EventRequest(BaseModel):
    event: Annotated[
        Annotated[OrderEvent, Tag("order")] | Annotated[UserEvent, Tag("user")],
        Discriminator(event_discriminator),
    ]
```

## Model Config

Use `ConfigDict` for all model configuration. Never use inner `class Config`.

```python
from pydantic import BaseModel, ConfigDict


class StrictInput(BaseModel):
    model_config = ConfigDict(
        strict=True,           # no type coercion
        frozen=True,           # immutable after creation
        extra="forbid",        # reject unknown fields
    )

    name: str
    value: int
```

### Strict Mode Guidance

- **Lax mode** (default): coerces types — `"123"` becomes `int(123)`. Use at API boundaries where HTTP data is always strings.
- **Strict mode**: requires exact Python types. Use for internal/domain models where type safety matters.

```python
# Lax — appropriate for API input schemas
class MarketCreate(BaseModel):
    name: str
    min_stake: int  # accepts "100" from JSON

# Strict — appropriate for Value Objects and internal models
class Money(BaseModel):
    model_config = ConfigDict(frozen=True, strict=True)
    amount: Decimal
    currency: str
```

**Note:** Strict mode from JSON is more lenient — it accepts ISO date strings for `datetime`, number strings for numeric types, etc. Strictness primarily matters for Python-to-Python validation.

## Settings Management

Use `pydantic-settings` for all application configuration. Settings live in the **infrastructure layer**.

### Basic Settings

```python
from pydantic import Field, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file=(".env",),
        env_file_encoding="utf-8",
        extra="ignore",
        env_ignore_empty=True,     # treat empty string as missing
        validate_default=True,     # validate default values too
    )

    database_url: str
    redis_url: str = "redis://localhost:6379/0"
    secret_key: SecretStr = Field(min_length=32)
    debug: bool = False
    log_level: str = "INFO"
```

**`SecretStr`** prevents accidental logging of sensitive values:
```python
settings = Settings()
print(settings.secret_key)                    # SecretStr('**********')
print(settings.secret_key.get_secret_value()) # actual value
```

### Nested Settings

Use `env_nested_delimiter` to map flat env vars to nested structures.

```python
from pydantic import BaseModel, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict


class DatabaseSettings(BaseModel):
    host: str = "localhost"
    port: int = 5432
    name: str = "app"
    user: str = "postgres"
    password: SecretStr


class CacheSettings(BaseModel):
    host: str = "localhost"
    port: int = 6379
    db: int = 0


class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_prefix="APP_",
        env_nested_delimiter="__",
        env_file=(".env",),
        env_file_encoding="utf-8",
        env_ignore_empty=True,
    )

    database: DatabaseSettings = DatabaseSettings()
    cache: CacheSettings = CacheSettings()
    debug: bool = False


# Env vars: APP_DATABASE__HOST=db.prod.local, APP_DATABASE__PORT=5433
settings = Settings()
```

Use `env_nested_max_split` when your delimiter is ambiguous:

```python
class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_nested_delimiter="_",
        env_nested_max_split=1,  # only split on first "_"
        env_prefix="GENERATION_",
    )
    llm: LLMConfig
# GENERATION_LLM_API_KEY -> llm.api_key (not llm.api.key)
```

### Secrets Management (Docker / Kubernetes)

```python
from pydantic import SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        secrets_dir="/run/secrets",  # Docker secrets, Kubernetes secrets
    )

    db_password: SecretStr
    api_key: SecretStr

# File /run/secrets/db_password contains the secret value
```

For nested secrets with directory structure, use `NestedSecretsSettingsSource`:

```python
from pydantic_settings import (
    BaseSettings,
    NestedSecretsSettingsSource,
    PydanticBaseSettingsSource,
    SettingsConfigDict,
)


class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        secrets_dir="secrets",
        secrets_nested_subdir=True,  # secrets/db/password file
    )

    @classmethod
    def settings_customise_sources(
        cls,
        settings_cls: type[BaseSettings],
        init_settings: PydanticBaseSettingsSource,
        env_settings: PydanticBaseSettingsSource,
        dotenv_settings: PydanticBaseSettingsSource,
        file_secret_settings: PydanticBaseSettingsSource,
    ) -> tuple[PydanticBaseSettingsSource, ...]:
        return (
            init_settings,
            env_settings,
            dotenv_settings,
            NestedSecretsSettingsSource(file_secret_settings),
        )
```

### Settings as a Dependency

Instantiate settings once. Inject via FastAPI dependency for testability.

```python
from functools import lru_cache

from app.config import Settings


@lru_cache(maxsize=1)
def get_settings() -> Settings:
    return Settings()
```

**Note:** `lru_cache` caches the settings instance for the process lifetime. This is appropriate for most cases. If you need to reload settings (e.g., in tests), clear the cache: `get_settings.cache_clear()`.

## Error Handling

### Catching ValidationError

Always catch `ValidationError` explicitly. Use `.errors()` for structured error data.

```python
from pydantic import BaseModel, ValidationError


class ItemCreate(BaseModel):
    name: str
    price: int


try:
    ItemCreate.model_validate({"price": "not_a_number"})
except ValidationError as e:
    for error in e.errors():
        print(error["loc"], error["type"], error["msg"])
```

### Formatting Errors for API Responses

Transform Pydantic errors into a consistent API error format.

```python
from pydantic import ValidationError
from fastapi import Request
from fastapi.responses import JSONResponse


def format_validation_errors(exc: ValidationError) -> list[dict[str, str]]:
    errors = []
    for error in exc.errors():
        field = ".".join(str(loc) for loc in error["loc"])
        errors.append({
            "field": field,
            "message": error["msg"],
            "type": error["type"],
        })
    return errors


async def validation_exception_handler(
    request: Request,
    exc: ValidationError,
) -> JSONResponse:
    return JSONResponse(
        status_code=422,
        content={
            "detail": "Validation failed",
            "errors": format_validation_errors(exc),
        },
    )
```

### Custom Error Messages in Validators

Raise `ValueError` with clear, user-facing messages. Never expose internal details.

```python
from pydantic import BaseModel, ValidationInfo, field_validator


class TransferCreate(BaseModel):
    amount: int
    source_account_id: str
    target_account_id: str

    @field_validator("amount")
    @classmethod
    def amount_must_be_positive(cls, v: int) -> int:
        if v <= 0:
            raise ValueError("Transfer amount must be greater than zero")
        return v

    @field_validator("target_account_id")
    @classmethod
    def accounts_must_differ(cls, v: str, info: ValidationInfo) -> str:
        if "source_account_id" in info.data and v == info.data["source_account_id"]:
            raise ValueError("Source and target accounts must be different")
        return v
```

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.