Skip to content

Pydantic + mypy (Practical Patterns)

Why this combo is tricky

  • Pydantic β†’ runtime validation
  • mypy β†’ static analysis

πŸ‘‰ mismatch: - Pydantic allows coercion - mypy assumes strict types


1. Basic Compatibility

from pydantic import BaseModel

class User(BaseModel):
    id: int

u = User(id=1)

reveal_type(u.id)  # int βœ…
````

Works fine because fields are properly typed.

---

## 2. The "Optional Hell"

```python
from typing import Optional

class Service:
    def __init__(self) -> None:
        self.user: Optional[User] = None

    def init(self) -> None:
        self.user = User(id=1)

    def run(self) -> int:
        return self.user.id  # ❌ mypy error

βœ… Fix 1: Assert

def run(self) -> int:
    assert self.user is not None
    return self.user.id

βœ… Fix 2: Guard clause (cleaner)

def run(self) -> int:
    if self.user is None:
        raise RuntimeError("Not initialized")
    return self.user.id

βœ… Fix 3: Private + property (best pattern)

class Service:
    def __init__(self) -> None:
        self._user: Optional[User] = None

    @property
    def user(self) -> User:
        if self._user is None:
            raise RuntimeError("Not initialized")
        return self._user

πŸ‘‰ Now:

  • internal = Optional
  • external = always safe

3. model_validate() vs constructor

data: dict[str, object]

user = User(**data)              # ❌ mypy complains
user = User.model_validate(data) # βœ… preferred

πŸ‘‰ Why:

  • constructor expects exact types
  • model_validate is designed for unknown input

4. Typed dict β†’ Pydantic

from typing import TypedDict

class UserDict(TypedDict):
    id: int
    name: str

def create_user(data: UserDict) -> User:
    return User(**data)  # βœ… safe now

5. Avoid "Any leakage"

Bad:

data = get_data()  # type: Any
user = User.model_validate(data)

Better:

def get_data() -> dict[str, object]:
    ...

Best:

def get_data() -> UserDict:
    ...

6. Validators + typing

from pydantic import field_validator

class User(BaseModel):
    age: int

    @field_validator("age")
    def check_age(cls, v: int) -> int:
        return v

πŸ‘‰ Always type v and return type.


7. Computed fields are invisible to mypy

class User(BaseModel):
    first: str
    last: str

    @property
    def full(self) -> str:
        return f"{self.first} {self.last}"

πŸ‘‰ mypy sees it fine only if it's a normal property Avoid relying on @computed_field for typing logic.


8. Extra fields safety

class User(BaseModel):
    model_config = {"extra": "forbid"}

πŸ‘‰ Helps catch bugs early (also aligns with mypy expectations)


9. Strict mode for sanity

class User(BaseModel):
    model_config = {"strict": True}
    id: int

πŸ‘‰ Prevents silent coercion β†’ matches mypy expectations


10. Lists & defaults

from pydantic import Field

class Good(BaseModel):
    items: list[int] = Field(default_factory=list)

πŸ‘‰ mypy + runtime safe


11. Pattern: Boundary vs Core

# boundary (Pydantic)
class UserModel(BaseModel):
    id: int
    name: str

# core (pure Python)
class User:
    def __init__(self, id: int, name: str) -> None:
        self.id = id
        self.name = name

Convert once:

def to_domain(u: UserModel) -> User:
    return User(id=u.id, name=u.name)

πŸ‘‰ This avoids mixing runtime + static worlds


12. When mypy fights you

❌ Don’t do this:

user = User.model_validate(data)
user.id + "abc"  # runtime fine? maybe, but mypy catches it

βœ… Trust mypy more than Pydantic

πŸ‘‰ If they disagree β†’ fix your types, not suppress errors


13. 🐒 mypy config (important)

  • pyproject.toml
[tool.mypy]
files = [ "src" ]
exclude = [  ]
install_types = true
non_interactive = true
disallow_untyped_defs = true
ignore_missing_imports = true
show_error_codes = true
warn_redundant_casts = true
warn_unused_configs = true
warn_unused_ignores = true
allow_redefinition = true
disable_error_code = "attr-defined"
warn_no_return = false

[[tool.mypy.overrides]]
# generate with:
# mypy --no-error-summary 2>&1 | tr ':' ' ' | awk '{print $1}' | sort | uniq | sed 's/\.py//g; s|src/||g; s|\/|\.|g'
module = [  ]
ignore_errors = true

14. Useful trick: cast (last resort)

from typing import cast

user = cast(User, something)

πŸ‘‰ Use only when you are 100% sure


TL;DR

  • Use Pydantic at boundaries (input/output)
  • Keep core logic strictly typed
  • Prefer model_validate over constructor for unknown data
  • Kill Optional early (assert / guard / property)
  • Avoid Any leaking

Mental Model

Pydantic = runtime safety mypy = compile-time safety

πŸ‘‰ You want BOTH, but clearly separated

```