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
β 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_validateis 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:
Better:
Best:
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
π Helps catch bugs early (also aligns with mypy expectations)
9. Strict mode for sanity
π Prevents silent coercion β matches mypy expectations
10. Lists & defaults
π 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:
π This avoids mixing runtime + static worlds
12. When mypy fights you
β Donβt do this:
β 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)
π Use only when you are 100% sure
TL;DR
- Use Pydantic at boundaries (input/output)
- Keep core logic strictly typed
- Prefer
model_validateover constructor for unknown data - Kill
Optionalearly (assert / guard / property) - Avoid
Anyleaking
Mental Model
Pydantic = runtime safety mypy = compile-time safety
π You want BOTH, but clearly separated
```