Type annotations catch obvious mistakes, but production input needs more than str and int. Names can be whitespace, arrays can be enormous, timestamps can be unreasonable, and unknown fields can hide client bugs.
Reject keys you do not support#
class CreateWorkoutRequest(BaseModel):
model_config = ConfigDict(extra='forbid')
name: str = Field(min_length=1, max_length=100)
intensity: Literal['low', 'moderate', 'high']
exercises: list[ExerciseInput] = Field(default_factory=list, max_length=100)
extra='forbid' catches a misspelled field instead of silently ignoring it. It also prevents a general update model from accepting fields such as owner_id that the endpoint never intended to expose.
Normalize before validating length#
@field_validator('name', mode='before')
@classmethod
def normalize_name(cls, value: object) -> str:
if not isinstance(value, str):
raise ValueError('name must be a string')
normalized = value.strip()
if not normalized:
raise ValueError('name cannot be empty')
return normalized
The application receives one canonical representation. Do not repeat trimming in every service.
Use model validation for relationships#
Field validators see one value. A model validator can enforce relationships:
@model_validator(mode='after')
def validate_load(self) -> Self:
if self.load_type == 'weighted' and self.external_load_kg is None:
raise ValueError('external_load_kg is required for weighted sets')
if self.load_type != 'weighted' and self.external_load_kg is not None:
raise ValueError('external_load_kg is only valid for weighted sets')
return self
This remains boundary validation because it checks whether the request is internally coherent.
Keep database decisions out#
“The session ID is a UUID” belongs in Pydantic. “The session exists, belongs to this user, and is still editable” belongs in a service because it requires persistence and authorization.
Use default_factory for mutable defaults, Literal or enums for closed values, and explicit upper bounds for lists and strings. Rejecting an oversized payload after it reaches an AI provider or a transaction is too late.
Good validation produces a small trusted input space. It does not try to replace application behavior.