Python Type Hints and Pydantic v2: Write Safer, Self-Documenting Code
Type hints catch bugs before runtime. Pydantic enforces them at the boundary. Together they transform Python from a duck-typed scripting language into a reliable production platform.
Contents
Why Type Hints Matter in Production
Python's dynamic typing is great for prototyping. But in a 50,000-line codebase with 10 engineers, it becomes a liability. Type hints solve three concrete problems:
- Catch bugs at write time -- your editor/mypy flags
process(user_id="abc")whenprocess(user_id: int)is expected - Self-documenting code -- function signatures become contracts, eliminating guesswork
- IDE superpowers -- full autocomplete, rename refactoring, and go-to-definition across the codebase
Modern Type Hint Syntax (Python 3.10+)
# Old way (still valid, but verbose)
from typing import Optional, Union, List, Dict, Tuple
def old_style(
name: Optional[str],
ids: List[int],
meta: Dict[str, Union[str, int]]
) -> Optional[Dict[str, List[int]]]:
...
# Modern way (Python 3.10+) -- cleaner, same semantics
def modern_style(
name: str | None,
ids: list[int],
meta: dict[str, str | int]
) -> dict[str, list[int]] | None:
...
Essential Built-in Types
from typing import Any, Never, TypeAlias, TypeVar, Callable, Awaitable
# Type aliases
UserId: TypeAlias = int
JsonDict: TypeAlias = dict[str, Any]
# TypeVar for generic functions
T = TypeVar("T")
def first(items: list[T]) -> T | None:
return items[0] if items else None
first([1, 2, 3]) # inferred return: int
first(["a", "b"]) # inferred return: str
# Callable type hints
Handler = Callable[[str, int], bool]
def run_handler(h: Handler, name: str, value: int) -> bool:
return h(name, value)
# Literal -- restrict to specific values
from typing import Literal
def set_mode(mode: Literal["read", "write", "append"]) -> None: ...
Generics and Protocols
from typing import Protocol, TypeVar, Generic, runtime_checkable
# Protocol -- structural subtyping (duck typing with type safety)
@runtime_checkable
class Serializable(Protocol):
def to_dict(self) -> dict[str, Any]: ...
def to_json(self) -> str: ...
class User:
def __init__(self, id: int, name: str):
self.id, self.name = id, name
def to_dict(self) -> dict[str, Any]:
return {"id": self.id, "name": self.name}
def to_json(self) -> str:
import json; return json.dumps(self.to_dict())
def export(obj: Serializable) -> str:
return obj.to_json() # works with any class that has these methods
# Generic class
T = TypeVar("T")
class Repository(Generic[T]):
def __init__(self):
self._store: dict[int, T] = {}
def save(self, id: int, obj: T) -> None:
self._store[id] = obj
def get(self, id: int) -> T | None:
return self._store.get(id)
user_repo = Repository[User]()
user_repo.save(1, User(1, "Alice"))
user: User | None = user_repo.get(1) # fully typed
Pydantic v2 Models
pip install pydantic[email]
from pydantic import BaseModel, EmailStr, HttpUrl, Field, computed_field
from datetime import datetime
from enum import Enum
from typing import Annotated
class Role(str, Enum):
admin = "admin"
editor = "editor"
viewer = "viewer"
# Annotated types -- reusable constraints
PositiveInt = Annotated[int, Field(gt=0)]
ShortStr = Annotated[str, Field(min_length=1, max_length=100)]
class UserCreate(BaseModel):
email: EmailStr
username: ShortStr
age: PositiveInt
role: Role = Role.viewer
website: HttpUrl | None = None
tags: list[str] = Field(default_factory=list)
model_config = {
"str_strip_whitespace": True,
"str_to_lower": False,
}
class UserResponse(UserCreate):
id: int
created_at: datetime
is_active: bool = True
@computed_field
@property
def display_name(self) -> str:
return f"@{self.username}"
# Validation at instantiation
try:
user = UserCreate(
email="alice@example.com",
username="alice",
age=30,
role="admin"
)
print(user.model_dump())
except ValueError as e:
print(e) # Pydantic gives detailed field-level errors
Nested Models and Serialisation
class Address(BaseModel):
street: str
city: str
country: str = "IN"
postcode: str | None = None
class Organisation(BaseModel):
id: int
name: ShortStr
address: Address
members: list[UserResponse] = []
org = Organisation(
id=1, name="Acme Corp",
address={"street": "123 Main St", "city": "Bangalore"}
)
# Serialise
print(org.model_dump())
print(org.model_dump_json(indent=2))
# Deserialise from JSON
raw = '{"id":1,"name":"Acme","address":{"street":"1 MG","city":"Bengaluru"}}'
org2 = Organisation.model_validate_json(raw)
Custom Validators
from pydantic import BaseModel, field_validator, model_validator, Field
class DateRange(BaseModel):
start_date: datetime
end_date: datetime
max_days: int = Field(default=365, gt=0)
@field_validator("end_date")
@classmethod
def end_must_be_future(cls, v: datetime) -> datetime:
if v <= datetime.utcnow():
raise ValueError("end_date must be in the future")
return v
@model_validator(mode="after")
def range_valid(self) -> "DateRange":
delta = (self.end_date - self.start_date).days
if delta <= 0:
raise ValueError("end_date must be after start_date")
if delta > self.max_days:
raise ValueError(f"Range exceeds {self.max_days} days (got {delta})")
return self
Settings Management with BaseSettings
pip install pydantic-settings
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import PostgresDsn, RedisDsn, SecretStr
class Settings(BaseSettings):
# Reads from environment variables or .env file
app_name: str = "MyApp"
debug: bool = False
database_url: PostgresDsn
redis_url: RedisDsn = "redis://localhost:6379/0"
secret_key: SecretStr # never printed in logs
allowed_hosts:list[str] = ["*"]
max_workers: int = Field(default=4, ge=1, le=64)
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
)
# Singleton pattern
from functools import lru_cache
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()
print(settings.database_url)
print(settings.secret_key.get_secret_value()) # explicit access required
mypy and pyright Static Analysis
# pyproject.toml
[tool.mypy]
python_version = "3.12"
strict = true
ignore_missing_imports = true
plugins = ["pydantic.mypy"]
[tool.pyright]
pythonVersion = "3.12"
typeCheckingMode = "strict"
# Run checks
mypy src/ # full project check
pyright src/ # Microsoft's faster alternative
# Common mypy flags
mypy --strict src/
mypy --ignore-missing-imports src/
[WARN] Common gotcha:
dict[str, Any] silences the type checker for that entire subtree. Prefer explicit models (Pydantic BaseModel or TypedDict) over Any wherever possible.[YES] Adoption strategy: Start with
mypy --ignore-missing-imports on new files only. Use # type: ignore sparingly to unblock -- but add a comment explaining why. Gradually increase strictness as the codebase matures.Key Takeaways: Use modern union syntax (X | None) over Optional[X]. Define Protocols for structural typing. Use Pydantic at every data boundary -- API input, config, DB serialisation. Enforce with mypy in CI.
PythonType HintsPydanticmypyStatic AnalysisClean Code
0 Comments