Python best practices
Runtime Validation of Python data objects
Data objects often represent values that other parts of a program rely on being valid. A type annotation alone does not validate values at runtime, and a standard dataclass accepts None for a field annotated as str unless validation is added explicitly.
When a project allows the dependency, use a Pydantic BaseModel for data objects that need validation at creation time. Declare required fields without defaults, use non-nullable types for fields that must not be None, and make the model frozen when its fields should not be reassigned.
Check out the following links for more information on Pydantic's BaseModel and Immutability
Using Pydantic
The following example uses Pydantic v2:
from pydantic import BaseModel, ConfigDict
class User(BaseModel):
model_config = ConfigDict(frozen=True)
user_id: int
email: str
Both fields are required because they have no defaults. Pydantic raises a ValidationError if either field is omitted or supplied as None:
User(user_id=1) # ValidationError: email is required User(user_id=None, email="user@example.com") # ValidationError: user_id must be an integer
The frozen model also rejects reassignment after construction:
user = User(user_id=1, email="user@example.com") user.email = "other@example.com" # ValidationError: the model is frozen
Do
- Use
ConfigDict(frozen=True)when callers must not reassign model fields. - Leave required fields without defaults. A default of
Nonemakes a field optional at construction. - Use non-nullable annotations, such as
strrather thanstr | None, whenNoneis invalid. - Prefer immutable types for contained collections, such as
tupleorfrozenset, when the data must not be changed after construction. Most container Abstract Base Classes imply immutablity and might be sufficient for your purposes. - Use
ConfigDict(strict=True)if values must match the declared types exactly instead of accepting Pydantic's supported conversions.
Do not
- Assume annotations alone reject invalid values at runtime.
- Declare a required field as
str | Noneor give it aNonedefault ifNoneis invalid. - Assume
frozen=Truemakes nested objects deeply immutable. A mutable list or dictionary held by a frozen model can still be changed; use immutable containers and frozen nested models where needed.
For example, frozen=True prevents assigning a different value to user.email, but it does not prevent mutation of a list stored in another field. Freezing a model is also not a substitute for validating data at system boundaries.
[Alternative] Runtime Validation using post_init
If adding Pydantic is not permitted, a standard-library dataclass can check required values in __post_init__:
from dataclasses import dataclass
@dataclass(frozen=True)
class User:
user_id: int
email: str
def __post_init__(self) -> None:
if self.user_id is None:
raise ValueError("user_id must not be None")
if self.email is None:
raise ValueError("email must not be None")