# Understanding Pydantic and Python Type Hints in Python Backend Development

> *Personal Knowledge Sharing: How moving from bare dicts to fully typed Pydantic models changed how I build and maintain Python backends*

***

## Introduction

Early in my Python backend work, every API endpoint function looked something like this:

```python
def create_job(payload: dict):
    name = payload["name"]
    profile = payload.get("profile")
    timeout = payload.get("timeout", 300)
    ...
```

No types. No validation. No guarantee that `timeout` was actually an integer or that `name` was present. The problems only surfaced at runtime — usually under load, or when an upstream service sent a slightly different payload shape.

When I started building `ansible-inspec`, a FastAPI-based compliance automation backend, I committed to type hints throughout and Pydantic v2 for all boundary validation (API requests, config loading, external data ingestion). The result was a codebase where shape mistakes were caught before they ever touched business logic.

This article is a deep-dive into both concepts from a backend perspective — what they solve, how they work together, and the patterns I actually reach for in production Python.

***

## Table of Contents

* [Why Type Hints Alone Are Not Enough at Runtime](#why-type-hints-alone-are-not-enough-at-runtime)
* [Python Type Hints Reference for Backend Work](#python-type-hints-reference-for-backend-work)
  * [Core Annotations](#core-annotations)
  * [Union Types and Optional](#union-types-and-optional)
  * [Generics with Built-in Collections](#generics-with-built-in-collections)
  * [TypedDict](#typeddict)
  * [TypeVar and Generic Classes](#typevar-and-generic-classes)
  * [Protocols as Structural Interfaces](#protocols-as-structural-interfaces)
  * [Literal and Final](#literal-and-final)
  * [Type Guards](#type-guards)
* [Where Pydantic Fits In](#where-pydantic-fits-in)
* [Pydantic v2 Fundamentals](#pydantic-v2-fundamentals)
  * [BaseModel and Field](#basemodel-and-field)
  * [Validation Rules with Field Constraints](#validation-rules-with-field-constraints)
  * [Custom Validators](#custom-validators)
  * [Nested Models](#nested-models)
  * [JSON Serialization and Deserialization](#json-serialization-and-deserialization)
  * [model\_config and Strict Mode](#model_config-and-strict-mode)
* [API Request and Response Models in FastAPI](#api-request-and-response-models-in-fastapi)
* [Pydantic for Configuration Management](#pydantic-for-configuration-management)
* [Testing Pydantic Models](#testing-pydantic-models)
* [Common Pitfalls and How I Avoid Them](#common-pitfalls-and-how-i-avoid-them)
* [What I Learned](#what-i-learned)

***

## Why Type Hints Alone Are Not Enough at Runtime

Python type hints are annotations — the interpreter does not enforce them at runtime. This is intentional; they exist for static analysis tools (`mypy`, `pyright`) and IDE intelligence, not for runtime safety.

```python
def set_timeout(timeout: int) -> None:
    print(f"Timeout set to {timeout}s")

# Python does not raise at runtime — it just runs
set_timeout("not-an-int")   # prints: Timeout set to not-an-int s
```

In a backend that receives data from HTTP requests, message queues, or environment variables, the data is always a string or untyped JSON until something enforces the shape. Mypy and pyright run at development time; they cannot help when a user sends `{"timeout": "five-minutes"}` to your API.

This is the gap Pydantic fills: it enforces your type hints at the boundary where untrusted data enters your system, and converts the failures into structured, human-readable errors.

{% @mermaid/diagram content="flowchart LR
A\[HTTP Request / Config File / Queue Message] --> B\[Pydantic Model]
B -->|valid| C\[Business Logic]
B -->|invalid| D\[ValidationError → 422 response]

```
style A fill:#78909C,color:#fff
style B fill:#1565C0,color:#fff
style C fill:#2E7D32,color:#fff
style D fill:#B71C1C,color:#fff" %}
```

***

## Python Type Hints Reference for Backend Work

### Core Annotations

```python
# Variables
job_id: str = "job-001"
retry_count: int = 3
is_dry_run: bool = False
timeout_seconds: float = 30.0

# Function signatures — always annotate return types
def build_uri(host: str, port: int = 22) -> str:
    return f"ssh://{host}:{port}"

# No return value
def emit_event(name: str, metadata: dict[str, str]) -> None:
    ...
```

### Union Types and Optional

Python 3.10+ uses the `|` operator directly, removing the need to import `Union` or `Optional` from `typing`:

```python
# 3.10+ union type
def parse_timeout(value: str | int | None) -> int:
    if value is None:
        return 300
    if isinstance(value, str):
        return int(value)
    return value

# Optional[X] is just X | None — prefer the | syntax in 3.10+
def get_profile_path(name: str) -> str | None:
    known = {
        "linux-baseline": "/profiles/dev-sec/linux-baseline",
        "cis-docker": "/profiles/cis/cis-docker-benchmark",
    }
    return known.get(name)
```

### Generics with Built-in Collections

Python 3.9 made built-in generics work without importing from `typing`:

```python
# Before 3.9 — required importing from typing
from typing import Dict, List, Tuple, Set

# 3.9+ — use the built-in types directly
def group_results(
    results: list[dict[str, str]]
) -> dict[str, list[dict[str, str]]]:
    groups: dict[str, list[dict[str, str]]] = {
        "passed": [],
        "failed": [],
        "skipped": [],
    }
    for result in results:
        status = result.get("status", "skipped")
        groups[status].append(result)
    return groups
```

### TypedDict

`TypedDict` is useful when you're working with dictionaries that have a known, fixed key schema — configuration files loaded from YAML/TOML, for instance:

```python
from typing import TypedDict, NotRequired

class InSpecConfig(TypedDict):
    profile: str
    reporter: str
    target: str
    sudo: NotRequired[bool]       # optional key
    input_file: NotRequired[str]  # optional key

def build_inspec_command(config: InSpecConfig) -> list[str]:
    cmd = ["inspec", "exec", config["profile"], "--reporter", config["reporter"]]
    if config.get("sudo"):
        cmd = ["sudo"] + cmd
    if input_file := config.get("input_file"):
        cmd += ["--input-file", input_file]
    return cmd
```

`TypedDict` gives you static checking but no runtime validation — if you need that, reach for Pydantic instead.

### TypeVar and Generic Classes

Generic classes let you build typed containers and repositories without duplicating code:

```python
from typing import TypeVar, Generic

T = TypeVar("T")

class ResultCache(Generic[T]):
    def __init__(self) -> None:
        self._store: dict[str, T] = {}

    def set(self, key: str, value: T) -> None:
        self._store[key] = value

    def get(self, key: str) -> T | None:
        return self._store.get(key)

    def keys(self) -> list[str]:
        return list(self._store.keys())

# Usage — type checker knows exactly what comes out
from pydantic import BaseModel

class JobResult(BaseModel):
    job_id: str
    status: str
    passed: int
    failed: int

cache: ResultCache[JobResult] = ResultCache()
cache.set("job-001", JobResult(job_id="job-001", status="success", passed=42, failed=0))
result = cache.get("job-001")  # type: JobResult | None
```

Python 3.12 introduced a cleaner syntax for generic classes with `type` statements, but the `TypeVar` + `Generic` pattern is still the most portable across 3.9+.

### Protocols as Structural Interfaces

`Protocol` enables structural subtyping ("duck typing" with static analysis support). I use it heavily in `ansible-inspec` to define adapter contracts without forcing inheritance:

```python
from typing import Protocol, runtime_checkable

@runtime_checkable
class InventoryAdapter(Protocol):
    def get_hosts(self, group: str) -> list[str]: ...
    def get_variables(self, host: str) -> dict[str, str]: ...

class StaticInventoryAdapter:
    """Reads from a flat YAML inventory file."""

    def __init__(self, path: str) -> None:
        self._path = path
        self._data: dict = {}

    def get_hosts(self, group: str) -> list[str]:
        return self._data.get("groups", {}).get(group, {}).get("hosts", [])

    def get_variables(self, host: str) -> dict[str, str]:
        return self._data.get("host_vars", {}).get(host, {})

class FakeInventoryAdapter:
    """Used in tests — no file system needed."""

    def __init__(self, hosts: dict[str, list[str]]) -> None:
        self._hosts = hosts

    def get_hosts(self, group: str) -> list[str]:
        return self._hosts.get(group, [])

    def get_variables(self, host: str) -> dict[str, str]:
        return {}

# The function accepts any object that satisfies the Protocol
def run_scan(adapter: InventoryAdapter, group: str) -> None:
    hosts = adapter.get_hosts(group)
    for host in hosts:
        variables = adapter.get_variables(host)
        print(f"Scanning {host} with variables: {variables}")
```

Neither `StaticInventoryAdapter` nor `FakeInventoryAdapter` inherits from `InventoryAdapter`. Mypy and pyright verify structural compatibility at check time.

### Literal and Final

`Literal` constrains a type to a fixed set of values. `Final` marks a variable as a constant:

```python
from typing import Literal, Final

# Literal — only these exact string values are valid
JobStatus = Literal["pending", "running", "success", "failed", "cancelled"]

def transition_job(current: JobStatus, next_status: JobStatus) -> bool:
    valid_transitions: dict[JobStatus, set[JobStatus]] = {
        "pending":   {"running", "cancelled"},
        "running":   {"success", "failed", "cancelled"},
        "success":   set(),
        "failed":    set(),
        "cancelled": set(),
    }
    return next_status in valid_transitions[current]

# Final — module-level constant
MAX_CONCURRENT_JOBS: Final[int] = 10
DEFAULT_PROFILE_DIR: Final[str] = "/opt/inspec/profiles"
```

### Type Guards

`TypeGuard` narrows a union type inside an `if` block when the guard function returns `True`:

```python
from typing import TypeGuard

def is_string_list(val: list[object]) -> TypeGuard[list[str]]:
    return all(isinstance(item, str) for item in val)

def process_tags(tags: list[object]) -> str:
    if is_string_list(tags):
        # Inside this block, `tags` is list[str]
        return ", ".join(tags)
    return ""
```

***

## Where Pydantic Fits In

Type hints define the *contract*. Pydantic enforces it.

The mental model I use:

| Layer            | Tool                         | Purpose                                      |
| ---------------- | ---------------------------- | -------------------------------------------- |
| Development      | `mypy --strict` / `pyright`  | Catch type errors before running code        |
| Runtime boundary | Pydantic `BaseModel`         | Validate and coerce external data on entry   |
| Internal logic   | Plain Python with type hints | Trust the types after Pydantic has validated |

You do not need Pydantic everywhere — only at boundaries where untrusted data enters the system. Internal functions that only receive already-validated domain objects do not need Pydantic models; plain type hints and mypy are sufficient.

***

## Pydantic v2 Fundamentals

Pydantic v2 rewrote the validation core in Rust. It is significantly faster than v1, but the API changed enough that migrating an existing codebase requires attention. If you are starting fresh, go straight to v2.

### BaseModel and Field

```python
from pydantic import BaseModel, Field
from datetime import datetime

class JobTemplate(BaseModel):
    name: str
    profile: str
    timeout: int = Field(default=300, ge=10, le=3600, description="Seconds, 10s–1h")
    supermarket: bool = False
    tags: list[str] = Field(default_factory=list)
    created_at: datetime = Field(default_factory=datetime.utcnow)

# Pydantic validates on instantiation
template = JobTemplate(name="linux-baseline", profile="dev-sec/linux-baseline")
print(template.timeout)        # 300
print(template.tags)           # []
print(template.created_at)     # 2026-03-27 ...

# Invalid input raises ValidationError
try:
    bad = JobTemplate(name="test", profile="dev-sec/test", timeout=5)
except Exception as e:
    print(e)
# 1 validation error for JobTemplate
# timeout
#   Input should be greater than or equal to 10 [type=greater_than_equal, ...]
```

### Validation Rules with Field Constraints

`Field` accepts a range of constraint arguments:

```python
from pydantic import BaseModel, Field
import re

class HostConfig(BaseModel):
    hostname: str = Field(min_length=1, max_length=253)
    port: int = Field(default=22, ge=1, le=65535)
    user: str = Field(default="root", min_length=1)
    connection_timeout: float = Field(default=30.0, gt=0.0, le=120.0)
    labels: dict[str, str] = Field(default_factory=dict, max_length=20)
```

For collection types, `max_length` limits the number of items. For strings, it limits character count.

### Custom Validators

When `Field` constraints are not enough, use `@field_validator`:

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

HOSTNAME_RE = re.compile(r"^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9\-]+)*$")

class HostConfig(BaseModel):
    hostname: str
    port: int = 22
    user: str = "root"

    @field_validator("hostname")
    @classmethod
    def validate_hostname(cls, v: str) -> str:
        # Allow IPv4 addresses too
        ipv4_re = re.compile(r"^\d{1,3}(\.\d{1,3}){3}$")
        if not (HOSTNAME_RE.match(v) or ipv4_re.match(v)):
            raise ValueError(f"'{v}' is not a valid hostname or IP address")
        return v.lower()

    @field_validator("port")
    @classmethod
    def validate_port(cls, v: int) -> int:
        if not (1 <= v <= 65535):
            raise ValueError(f"Port {v} is out of range 1–65535")
        return v
```

For validators that need to access multiple fields simultaneously, use `@model_validator`:

```python
from pydantic import BaseModel, model_validator
from typing import Self

class ScanTarget(BaseModel):
    hostname: str = ""
    ip_address: str = ""
    port: int = 22

    @model_validator(mode="after")
    def require_hostname_or_ip(self) -> Self:
        if not self.hostname and not self.ip_address:
            raise ValueError("At least one of 'hostname' or 'ip_address' must be set")
        return self
```

### Nested Models

Composition is where Pydantic's design really pays off. Pydantic validates the entire object graph, including nested models, and the error messages point to the exact field path that failed:

```python
from pydantic import BaseModel, Field
from datetime import datetime
from enum import Enum

class JobStatus(str, Enum):
    PENDING = "pending"
    RUNNING = "running"
    SUCCESS = "success"
    FAILED = "failed"

class ControlResult(BaseModel):
    control_id: str
    title: str
    status: Literal["passed", "failed", "skipped"]
    message: str | None = None
    duration_ms: float | None = None

class HostScanResult(BaseModel):
    hostname: str
    scanned_at: datetime
    controls: list[ControlResult] = Field(default_factory=list)

    @property
    def passed_count(self) -> int:
        return sum(1 for c in self.controls if c.status == "passed")

    @property
    def failed_count(self) -> int:
        return sum(1 for c in self.controls if c.status == "failed")

class JobResult(BaseModel):
    job_id: str
    template_name: str
    status: JobStatus = JobStatus.PENDING
    started_at: datetime | None = None
    finished_at: datetime | None = None
    host_results: list[HostScanResult] = Field(default_factory=list)

    @property
    def duration_seconds(self) -> float | None:
        if self.started_at and self.finished_at:
            return (self.finished_at - self.started_at).total_seconds()
        return None

    def total_failed(self) -> int:
        return sum(h.failed_count for h in self.host_results)
```

A deeply nested validation error from Pydantic looks like:

```
1 validation error for JobResult
host_results.0.controls.2.status
  Input should be 'passed', 'failed' or 'skipped' [type=literal_error, ...]
```

The dot-separated path tells you exactly where the problem is without any debugging.

### JSON Serialization and Deserialization

Pydantic v2 uses `model_dump()` and `model_dump_json()` for serialization, and `model_validate()` / `model_validate_json()` for deserialization:

```python
from pydantic import BaseModel
from datetime import datetime

class JobTemplate(BaseModel):
    name: str
    profile: str
    timeout: int = 300
    created_at: datetime

template = JobTemplate(
    name="cis-docker",
    profile="cis/cis-docker-benchmark",
    created_at=datetime(2026, 3, 27, 10, 0, 0),
)

# To Python dict
data = template.model_dump()
# {'name': 'cis-docker', 'profile': 'cis/cis-docker-benchmark',
#  'timeout': 300, 'created_at': datetime(2026, 3, 27, 10, 0, 0)}

# To JSON string — datetime becomes ISO-8601
json_str = template.model_dump_json()
# '{"name":"cis-docker","profile":"cis/cis-docker-benchmark",
#   "timeout":300,"created_at":"2026-03-27T10:00:00"}'

# From dict
raw = {"name": "ssh-baseline", "profile": "dev-sec/ssh-baseline", "created_at": "2026-03-27T10:00:00"}
t2 = JobTemplate.model_validate(raw)   # string → datetime conversion is automatic

# From JSON string
t3 = JobTemplate.model_validate_json(json_str)

# From file
with open("template.json") as f:
    t4 = JobTemplate.model_validate_json(f.read())
```

You can control field names in the serialized output with `serialization_alias` and exclude fields:

```python
from pydantic import BaseModel, Field

class JobTemplateResponse(BaseModel):
    name: str
    profile: str
    timeout: int = 300
    internal_id: str = Field(exclude=True)   # never appears in output
    profile_path: str = Field(serialization_alias="profilePath")

t = JobTemplateResponse(
    name="cis-docker",
    profile="cis/cis-docker",
    internal_id="uuid-abc",
    profile_path="/opt/inspec/profiles/cis-docker",
)
print(t.model_dump(by_alias=True))
# {'name': 'cis-docker', 'profile': 'cis/cis-docker',
#  'timeout': 300, 'profilePath': '/opt/inspec/profiles/cis-docker'}
```

### model\_config and Strict Mode

`model_config` centralises all model-level settings:

```python
from pydantic import BaseModel, ConfigDict, Field

class StrictJobTemplate(BaseModel):
    model_config = ConfigDict(
        strict=True,           # no type coercion — "300" will NOT become 300
        frozen=True,           # immutable after creation (makes the model hashable)
        extra="forbid",        # reject payloads with unknown keys
        populate_by_name=True, # allow both field name and alias on input
        str_strip_whitespace=True,  # strip leading/trailing whitespace from strings
    )

    name: str
    profile: str
    timeout: int = 300
```

I use `extra="forbid"` on all request models coming from external sources. It prevents clients from accidentally depending on fields that don't exist and catches typos in field names early:

```python
try:
    StrictJobTemplate(name="test", profile="test", timout=60)  # typo: timout
except Exception as e:
    print(e)
# 1 validation error for StrictJobTemplate
# timout
#   Extra inputs are not permitted [type=extra_forbidden, ...]
```

***

## API Request and Response Models in FastAPI

FastAPI is built on Pydantic — route function parameters declared as Pydantic models are automatically validated from the request body, and return type annotations drive response serialization.

```python
from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel, ConfigDict, Field
from datetime import datetime
from enum import Enum

app = FastAPI()


class JobStatus(str, Enum):
    PENDING = "pending"
    RUNNING = "running"
    SUCCESS = "success"
    FAILED = "failed"


# Separate request and response models are important:
# - request models validate and restrict what clients can send
# - response models control what clients receive (no internal fields leak)

class CreateJobRequest(BaseModel):
    model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)

    template_name: str = Field(min_length=1, max_length=100)
    target_hosts: list[str] = Field(min_length=1, max_length=500)
    timeout: int = Field(default=300, ge=10, le=3600)
    dry_run: bool = False


class JobResponse(BaseModel):
    job_id: str
    template_name: str
    status: JobStatus
    target_hosts: list[str]
    created_at: datetime
    started_at: datetime | None = None
    finished_at: datetime | None = None


# In-memory store for illustration purposes
_jobs: dict[str, JobResponse] = {}


@app.post("/jobs", response_model=JobResponse, status_code=status.HTTP_201_CREATED)
async def create_job(body: CreateJobRequest) -> JobResponse:
    job_id = f"job-{len(_jobs) + 1:05d}"
    job = JobResponse(
        job_id=job_id,
        template_name=body.template_name,
        status=JobStatus.PENDING,
        target_hosts=body.target_hosts,
        created_at=datetime.utcnow(),
    )
    _jobs[job_id] = job
    return job


@app.get("/jobs/{job_id}", response_model=JobResponse)
async def get_job(job_id: str) -> JobResponse:
    job = _jobs.get(job_id)
    if job is None:
        raise HTTPException(status_code=404, detail=f"Job '{job_id}' not found")
    return job
```

FastAPI automatically translates `ValidationError` into an HTTP 422 response with a structured body listing all field errors. No manual error handling is required for input validation.

{% @mermaid/diagram content="sequenceDiagram
participant C as Client
participant F as FastAPI
participant P as Pydantic
participant H as Handler

```
C->>F: POST /jobs {body}
F->>P: Validate CreateJobRequest
alt valid
    P-->>F: CreateJobRequest instance
    F->>H: create_job(body)
    H-->>F: JobResponse
    F->>P: Serialize JobResponse
    P-->>F: JSON
    F-->>C: 201 {JSON}
else invalid
    P-->>F: ValidationError
    F-->>C: 422 {errors}
end" %}
```

***

## Pydantic for Configuration Management

`pydantic-settings` extends Pydantic to read configuration from environment variables and `.env` files. Every field is validated with the same `BaseModel` machinery:

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

class DatabaseSettings(BaseSettings):
    model_config = SettingsConfigDict(
        env_prefix="DATABASE__",    # DATABASE__URL, DATABASE__POOL_SIZE, etc.
        env_file=".env",
        env_file_encoding="utf-8",
    )

    url: str = Field(
        default="postgresql+asyncpg://ansible:ansible@localhost:5432/ansible_inspec"
    )
    pool_size: int = Field(default=10, ge=1, le=100)
    max_overflow: int = Field(default=20, ge=0, le=200)
    echo_sql: bool = False


class AppSettings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")

    debug: bool = False
    log_level: str = Field(default="INFO", pattern=r"^(DEBUG|INFO|WARNING|ERROR|CRITICAL)$")
    api_version: str = "v1"
    max_concurrent_jobs: int = Field(default=5, ge=1, le=50)
    database: DatabaseSettings = Field(default_factory=DatabaseSettings)


# Settings are validated and available immediately on import
settings = AppSettings()

# Environment variables override defaults:
# DATABASE__POOL_SIZE=20 python -m ansible_inspec
# LOG_LEVEL=DEBUG python -m ansible_inspec
```

I keep settings as a module-level singleton rather than a dependency in every route. The validation happens once at startup, and a misconfigured environment fails immediately with a clear error instead of silently misbehaving under load.

***

## Testing Pydantic Models

Pydantic models are straightforward to test because they are plain Python objects. No database, no HTTP server required.

```python
import pytest
from pydantic import ValidationError
from datetime import datetime

from myapp.models import CreateJobRequest, JobTemplate, HostConfig


class TestCreateJobRequest:
    def test_valid_request(self) -> None:
        req = CreateJobRequest(
            template_name="linux-baseline",
            target_hosts=["web-01", "web-02"],
        )
        assert req.timeout == 300
        assert req.dry_run is False

    def test_empty_template_name_rejected(self) -> None:
        with pytest.raises(ValidationError) as exc_info:
            CreateJobRequest(template_name="", target_hosts=["web-01"])
        errors = exc_info.value.errors()
        assert any(e["loc"] == ("template_name",) for e in errors)

    def test_empty_target_hosts_rejected(self) -> None:
        with pytest.raises(ValidationError) as exc_info:
            CreateJobRequest(template_name="linux-baseline", target_hosts=[])
        errors = exc_info.value.errors()
        assert any(e["loc"] == ("target_hosts",) for e in errors)

    def test_timeout_below_minimum_rejected(self) -> None:
        with pytest.raises(ValidationError) as exc_info:
            CreateJobRequest(
                template_name="linux-baseline",
                target_hosts=["web-01"],
                timeout=5,
            )
        errors = exc_info.value.errors()
        assert any(e["loc"] == ("timeout",) for e in errors)

    def test_unknown_field_rejected(self) -> None:
        with pytest.raises(ValidationError):
            CreateJobRequest(
                template_name="linux-baseline",
                target_hosts=["web-01"],
                unknown_field="value",    # extra="forbid" should catch this
            )

    def test_whitespace_stripped_from_template_name(self) -> None:
        req = CreateJobRequest(
            template_name="  linux-baseline  ",
            target_hosts=["web-01"],
        )
        assert req.template_name == "linux-baseline"


class TestHostConfig:
    def test_valid_hostname(self) -> None:
        config = HostConfig(hostname="web-01.internal")
        assert config.hostname == "web-01.internal"

    def test_ip_address_accepted(self) -> None:
        config = HostConfig(hostname="192.168.1.10")
        assert config.hostname == "192.168.1.10"

    def test_invalid_hostname_rejected(self) -> None:
        with pytest.raises(ValidationError):
            HostConfig(hostname="invalid hostname with spaces")

    def test_default_port(self) -> None:
        config = HostConfig(hostname="web-01")
        assert config.port == 22
```

The key principle is to test the boundary conditions of your validators explicitly. Each custom `@field_validator` and `@model_validator` should have at least one test for the valid path and one for the invalid path.

***

## Common Pitfalls and How I Avoid Them

### 1. Sharing the same model for request input and response output

Request models should be strict (`extra="forbid"`, no internal fields). Response models should expose only what clients need and can include computed or joined data. Mixing the two couples your API surface to your internal domain objects.

```python
# Separate models
class CreateTemplateRequest(BaseModel):
    model_config = ConfigDict(extra="forbid")
    name: str
    profile: str

class TemplateResponse(BaseModel):
    id: str         # generated internally
    name: str
    profile: str
    created_at: datetime
```

### 2. Using `dict` as a type hint when you mean a Pydantic model

If a function receives a dict and immediately constructs a Pydantic model from it, move the validation to the function signature:

```python
# Weak — validation happens inside, not at the interface
def process_result(data: dict) -> None:
    result = JobResult.model_validate(data)
    ...

# Better — callers must pass a validated model
def process_result(result: JobResult) -> None:
    ...
```

### 3. Mutable default values in models

Pydantic handles this correctly when you use `Field(default_factory=...)`, but a plain mutable default will either raise an error or silently share state:

```python
# Wrong — Pydantic v2 will raise a warning or error
class JobResult(BaseModel):
    controls: list[str] = []             # mutable default

# Correct
class JobResult(BaseModel):
    controls: list[str] = Field(default_factory=list)
```

### 4. Ignoring model\_config on base classes

In a class hierarchy, each child class inherits `model_config` settings. Set the config on a shared base class to avoid repeating it:

```python
from pydantic import BaseModel, ConfigDict

class AppModel(BaseModel):
    """Base model with project-wide defaults."""
    model_config = ConfigDict(
        extra="forbid",
        str_strip_whitespace=True,
        populate_by_name=True,
    )

class CreateJobRequest(AppModel):
    template_name: str
    target_hosts: list[str]

class UpdateJobRequest(AppModel):
    status: str
```

### 5. Catching `ValidationError` too broadly in middleware

`pydantic.ValidationError` contains structured error details. In FastAPI this is handled automatically. In other contexts, extract the errors rather than converting the exception to a plain string:

```python
from pydantic import BaseModel, ValidationError

class Config(BaseModel):
    db_url: str
    pool_size: int = 10

def load_config(data: dict) -> Config:
    try:
        return Config.model_validate(data)
    except ValidationError as e:
        # e.errors() returns a list of dicts with location, message, and type
        for error in e.errors():
            print(f"Config error at {'.'.join(str(x) for x in error['loc'])}: {error['msg']}")
        raise
```

***

## What I Learned

Working through `ansible-inspec` and subsequent FastAPI projects taught me a clear mental hierarchy:

1. **Type hints are for humans and tools** — they document intent and enable static analysis, but they do not protect you from bad data at runtime.
2. **Pydantic is the runtime enforcer at your data boundary** — HTTP request bodies, environment variables, file-loaded configs, and queue messages are all untrusted until Pydantic validates them.
3. **Separate request and response models early** — conflating them creates coupling that is painful to undo later.
4. **`extra="forbid"` on all inbound models** — this one setting catches typos, dropped fields, and client-side contract drift before they become silent bugs.
5. **Keep internal domain objects plain Python with type hints** — once data is validated by Pydantic at the edge, inner service functions should receive typed domain objects, not raw dicts or repeated Pydantic models.
6. **`pydantic-settings` makes twelve-factor config easy** — environment variable injection, `.env` loading, and strong validation in one place, failing fast at startup if something is misconfigured.

The combination of `mypy --strict` at development time and Pydantic at runtime boundaries gives Python a safety profile closer to a compiled, statically typed language — without losing the expressiveness that makes Python the right tool for most backend work.
