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 None makes a field optional at construction.
  • Use non-nullable annotations, such as str rather than str | None, when None is invalid.
  • Prefer immutable types for contained collections, such as tuple or frozenset, 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 | None or give it a None default if None is invalid.
  • Assume frozen=True makes 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")