Skip to content

How to Validate Attributes with Descriptors in Python

Learn how to use Python descriptors to create reusable attribute validators that enforce type, range, and custom rules without code duplication.

Attribute validation is essential for ensuring data integrity within objects. While properties (@property) are the standard way to validate individual attributes, repeating the same validation logic (e.g., checking for positive integers) across multiple attributes or classes leads to code duplication.

Descriptors offer a powerful, reusable way to encapsulate attribute management logic. A descriptor is a class that defines how an attribute is accessed (__get__) and modified (__set__).

This guide explains how to implement descriptors for robust attribute validation.

Understanding the Descriptor Protocol

A descriptor is simply a class that implements at least one of these methods:

  • __get__(self, instance, owner): Retrieve the value.
  • __set__(self, instance, value): Set the value.
  • __delete__(self, instance): Delete the value.

When you assign a descriptor instance to a class attribute, Python delegates attribute access to these methods.

Method 1: Basic Validation Descriptor

Let's create a descriptor that ensures an attribute is always a positive integer.

class PositiveInteger:
    def __init__(self, name):
        self.name = name  # The internal name to store the value

    def __get__(self, instance, owner):
        return instance.__dict__[self.name]

    def __set__(self, instance, value):
        if not isinstance(value, int) or value <= 0:
            raise ValueError(f"{self.name} must be a positive integer")
        instance.__dict__[self.name] = value

class Product:
    price = PositiveInteger("price")
    quantity = PositiveInteger("quantity")

    def __init__(self, name, price, quantity):
        self.name = name
        self.price = price       # Calls PositiveInteger.__set__
        self.quantity = quantity # Calls PositiveInteger.__set__

p = Product("Laptop", 1000, 5)
print(f"Price: {p.price}")

try:
    p.price = -50
except ValueError as e:
    print(f"Error: {e}")

Output:

Price: 1000
Error: price must be a positive integer

Method 2: Using __set_name__ (Python 3.6+)

Python 3.6 introduced __set_name__, which is automatically called when the descriptor is instantiated. This allows the descriptor to know the name of the variable it is assigned to, removing the need to pass it manually.

class ValidString:
    def __set_name__(self, owner, name):
        self.public_name = name
        self.private_name = '_' + name

    def __get__(self, instance, owner):
        return getattr(instance, self.private_name)

    def __set__(self, instance, value):
        if not isinstance(value, str):
            raise TypeError(f"{self.public_name} must be a string")
        if len(value) == 0:
            raise ValueError(f"{self.public_name} cannot be empty")
        setattr(instance, self.private_name, value)

class User:
    username = ValidString() # Name automatically captured as 'username'
    email = ValidString()    # Name automatically captured as 'email'

    def __init__(self, username, email):
        self.username = username
        self.email = email

user = User("alice", "alice@example.com")
print(f"User: {user.username}")

try:
    user.username = ""
except ValueError as e:
    print(f"Error: {e}")

Output:

User: alice
Error: username cannot be empty

Method 3: Creating a Reusable Validator Class

We can generalize this pattern further by creating a base Validator class that accepts check functions.

class Validator:
    def __init__(self, *checks):
        self.checks = checks

    def __set_name__(self, owner, name):
        self.private_name = '_' + name

    def __get__(self, instance, owner):
        return getattr(instance, self.private_name)

    def __set__(self, instance, value):
        for check in self.checks:
            check(value)
        setattr(instance, self.private_name, value)

def is_int(val):
    if not isinstance(val, int): raise TypeError("Must be an integer")

def is_positive(val):
    if val <= 0: raise ValueError("Must be positive")

def is_even(val):
    if val % 2 != 0: raise ValueError("Must be even")

class Config:
    max_connections = Validator(is_int, is_positive)
    buffer_size = Validator(is_int, is_even)

    def __init__(self, conns, buf):
        self.max_connections = conns
        self.buffer_size = buf

try:
    c = Config(10, 3)
except ValueError as e:
    print(f"Error: {e}")

Output:

Error: Must be even

Conclusion

To validate attributes using descriptors efficiently:

  1. Implement __set__ to intercept assignments and run validation logic.
  2. Use __set_name__ to automatically capture the attribute name for error messages and storage.
  3. Store Values in instance.__dict__ or using setattr with a private name to avoid infinite recursion.
Navigation