Run-Time Data Validation Frameworks in Python

Author
Dr. Nicholas A. Del Grosso

Explore a spectrum of runtime validation tools, from lightweight type enforcement to structured domain objects and full validation models.

Section 1: Background

Static type checkers (like mypy) only analyze code before it runs; they can’t stop bad data at runtime. Python’s dynamic nature means that incorrect types or malformed objects can easily slip through unless you enforce rules during execution. Run-time validation libraries fill that gap by adding guardrails that catch incorrect values while the program is running. This notebook walks through the spectrum—from lightweight decorators to full-featured validation frameworks—so you understand how they differ and when each is appropriate.

Instead of relying on one monolithic tool, Python gives you building blocks: type hinting, decorators, structured data classes, and validation utilities. Together, they let you enforce contracts in plain Python code without committing to a heavyweight framework. This section explains those parts and how they fit together in real systems.

Section 2: Type Checking with beartype

beartype adds runtime type enforcement to ordinary Python functions and classes. It inspects type hints and inserts fast checks that run whenever a function is called or a dataclass is instantiated. The key insight: type hints are normally ignored by Python, but beartype makes them real. This section highlights the strengths and limitations of this approach—especially how easy it is to bypass if you’re not careful with object mutation.

Exercises

Exercise: Find two ways to run this function and violate the type hints, without raising an error.

def add(x: int, y: int) -> float:
    return x + y
Solution
add("1", "2")
add([1], [2])

Exercise: below is the same add function, this time with the @beartype decorator. Then run the your solutions from the previous exercise again, and see if it can get past beartype without raising an error. What changed?

from beartype import beartype

@beartype
def add(x: int, y: int) -> float:
    return x + y
Solution
from beartype.roar import BeartypeCallHintParamViolation

try:
    add("1", "2")
except BeartypeCallHintParamViolation as exc:
    print(type(exc).__name__)

try:
    add([1], [2])
except BeartypeCallHintParamViolation as exc:
    print(type(exc).__name__)

# beartype raises before the function body runs because the arguments do not match the type hints.

Exercise: Find two different ways to instantiate the Person object below, that violates its type annotations, but doesn’t raise an error.

from dataclasses import dataclass

@dataclass
class Person:
    name: str
    age: int
Solution
Person(name=123, age="old")
Person(name=["Emma"], age=None)

Exercise: add @beartype above the @dataclass decorator on the Person dataclass, and re-run the previous exercises. Does beartype catch the violations?

from dataclasses import dataclass
from beartype import beartype
from beartype.roar import BeartypeCallHintParamViolation

@dataclass
class Person:
    name: str
    age: int

for args in [(123, "old"), (["Emma"], None)]:
    try:
        Person(*args)
    except BeartypeCallHintParamViolation as exc:
        print(type(exc).__name__)
Solution
from dataclasses import dataclass
from beartype import beartype
from beartype.roar import BeartypeCallHintParamViolation

@beartype
@dataclass
class Person:
    name: str
    age: int

for args in [(123, "old"), (["Emma"], None)]:
    try:
        Person(*args)
    except BeartypeCallHintParamViolation as exc:
        print(type(exc).__name__)

Exercise: Where is beartype not working? Hack beartype by manipulating a Person instance’s field type after the object has already been created. This should work–beartype won’t catch it. Print the violated object. (Note: PyLance/MyPy should catch this issue and show a red squiggly, even though beartype won’t)

Solution
person = Person(name="Emma", age=3)
person.age = "old"
print(person)

Section 3: Validated Domain Objects with attrs

attrs focuses on building clean, explicit data models. It generates boilerplate for you (like init and repr), but it also supports field validators that run whenever you create an object. This turns plain Python classes into reliable domain objects that enforce invariants such as “age must be positive.” The section shows how to use types, converters, and custom validators to encode your rules directly in the data structure.

Type Validation with attrs

attrs doesn’t validate types automatically—you must opt in. Once you do, you get strict checking on every field assignment, which makes domain models safer and easier to reason about. This part demonstrates how that works and what kinds of structural guarantees you can enforce.

Exercises

Example: Make an attrs-based object with this interface: Rectangle(length=4.3, width=1.2):

from attr import define, field


@define
class Rectangle:
    length: float = field(converter=float)
    width: float = field(converter=float)


Rectangle(3, 4)
Rectangle(width=3.0, length=4.0)

Exercise: Make an attrs-based object with this interface: Person(name='Emma', age=3):

Solution
from attr import define

@define
class Person:
    name: str
    age: int

Person(name="Emma", age=3)

Exercise: Ensure the Person produces valid types on intance creation: Does it raise an error if the wrong type is supplied to a field?

Solution
from attr import define, field, validators

@define
class Person:
    name: str = field(validator=validators.instance_of(str))
    age: int = field(validator=validators.instance_of(int))

Person(name="Emma", age=3)

try:
    Person(name=123, age="old")
except TypeError as exc:
    print(type(exc).__name__)

Exercise: Ensure the Person produces valid types on intance modification: Does it raise an error if the wrong type is supplied to a field?

Solution
from attr import define, field, setters, validators

@define(on_setattr=setters.validate)
class Person:
    name: str = field(validator=validators.instance_of(str))
    age: int = field(validator=validators.instance_of(int))

person = Person(name="Emma", age=3)

try:
    person.age = "old"
except TypeError as exc:
    print(type(exc).__name__)

Value Validation with attrs

Built-in validators are useful, but real-world data almost always needs custom rules. This section shows how to write small validator functions that enforce domain-specific logic (e.g., non-empty strings, positive numbers). These validators scale nicely as your application grows.

Built-In Validators Reference: https://www.attrs.org/en/stable/api.html#module-attrs.validators

Validator Description
validators.lt() Check a value is less than some threshold
validators.le() Check a value is less-than or equal-to some threshold
validators.gt() Check a value is greater than some threshold
validators.ge() Check a value is greater-than or equal-to some threshold
validators.in_() Check a substring is inside

Custom validators can aso be made.

Example: Make sure that a string is never empty:

from attr import define, field, validators

def non_blank_string(instance, attribute, value: str):
    if len(value) == 0:
        raise ValueError(f"{attribute.name} must not be blank")

@define
class Contact:
    email: str = field(converter=str, validator=[non_blank_string])

Contact(email='')
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[23], line 11
      7 @define
      8 class Contact:
      9     email: str = field(converter=str, validator=[non_blank_string])
---> 11 Contact(email='')

File <attrs generated methods __main__.Contact>:26, in __init__(self, email)
     24 _setattr('email', __attr_converter_email(email))
     25 if _config._run_validators is True:
---> 26     __attr_validator_email(self, __attr_email, self.email)

File c:\Users\delgr\Projects\hhai-repo\.pixi\envs\default\Lib\site-packages\attr\_make.py:3279, in _AndValidator.__call__(self, inst, attr, value)
   3277 def __call__(self, inst, attr, value):
   3278     for v in self._validators:
-> 3279         v(inst, attr, value)

Cell In[23], line 5, in non_blank_string(instance, attribute, value)
      3 def non_blank_string(instance, attribute, value: str):
      4     if len(value) == 0:
----> 5         raise ValueError(f"{attribute.name} must not be blank")

ValueError: email must not be blank

Exercise: Either using custom validator functions or supplied validator functions from attrs.validators, make a Person(name: str, age: int) data structure that requires:

  1. The first letter of Person.name is upper-cased (hint: str.isupper())
  2. Person.age is positive.
Solution
from attr import define, field, setters, validators

def starts_with_uppercase(instance, attribute, value: str):
    if not value or not value[0].isupper():
        raise ValueError(f"{attribute.name} must start with an uppercase letter")

@define(on_setattr=setters.validate)
class Person:
    name: str = field(validator=[validators.instance_of(str), starts_with_uppercase])
    age: int = field(validator=[validators.instance_of(int), validators.ge(0)])

Person(name="Emma", age=3)

Section 4: Larger Frameworks for Data Validation: Exploring Pydantic

Pydantic goes beyond simple containers and acts as a full validation engine. It parses input, coerces types, runs validators, and produces clean, stable objects that you can safely depend on. It’s especially useful when reading untrusted data (e.g. JSON, API requests, configuration files) because it refuses malformed inputs by default. This section illustrates why many production systems adopt Pydantic for reliability.

Validated Objects

Pydantic models automatically enforce type rules and transform incoming values. You write your domain schema once, and Pydantic handles the conversions and checks. Here, you see how to enforce positivity, string constraints, and other common rules without extra boilerplate.

Exercises

Example: Create a Rectangle(length: float, width: float) object with pydantic`, requiring:

  1. both length and width are always positive
from pydantic import BaseModel, Field, ConfigDict

class Rectangle(BaseModel):
    model_config = ConfigDict(validate_assignment=True)
    length: float = Field(ge=0)
    width: float = Field(ge=0)


Rectangle(length=3, width=4)
Rectangle(length=3.0, width=4.0)

Exercise: Create a Person(name: str, age: int) Object with Pydantic, requiring:

  1. The first letter of Person.name is never empty (hint: min_length=0)
  2. Person.age is always positive (hing: ge=0).
Solution
from pydantic import BaseModel, Field, ConfigDict

class Person(BaseModel):
    model_config = ConfigDict(validate_assignment=True)
    name: str = Field(min_length=1)
    age: int = Field(ge=0)

Person(name="Emma", age=3)

Custom Validators in Pydantic

When built-in constraints aren’t enough, you can add custom logic using decorator-based validators. These run after parsing and give you full control over domain rules. This section demonstrates how custom validation behaves differently from field constraints and how to combine them effectively.

Example: Using custom validators, create a Rectangle(length: float, width: float) object with pydantic, requiring:

  1. both length and width are always positive
from pydantic import BaseModel, ConfigDict, field_validator

class Rectangle(BaseModel):
    model_config = ConfigDict(validate_assignment=True)
    length: float
    width: float

    @field_validator('length', 'width')
    def _validate_is_positive(cls, value: float) -> float:
        if value < 0:
            raise ValueError('must be positive.')
        return value

Rectangle(length=3, width=4)
Rectangle(length=3.0, width=4.0)

Exercise: Use pydantic.field_validator to make a custom validator: Make a Person object that validates that name is always title-cased.

Solution
from pydantic import BaseModel, ConfigDict, field_validator

class Person(BaseModel):
    model_config = ConfigDict(validate_assignment=True)
    name: str
    age: int

    @field_validator("name")
    @classmethod
    def validate_title_case(cls, value: str) -> str:
        if not value.istitle():
            raise ValueError("name must be title-cased")
        return value

Person(name="Emma", age=3)

Section 5: Conclusion

Runtime data validation is essential when working in Python’s dynamic environment, where type hints alone don’t guarantee correctness. The tools explored in this notebook—beartype, attrs, and pydantic—sit at different points on the spectrum of complexity and strictness. beartype adds lightweight runtime checks to existing functions, attrs gives you explicit and maintainable domain objects with customizable validation, and pydantic provides a full validation and parsing engine suitable for production systems handling untrusted data. Understanding the trade-offs between these approaches helps you pick the right level of enforcement for each layer of your application.

Section 6: References