Skip to content
← Back to explainers

Python explainer

Validate a Field on Every Assignment with a Descriptor

About 4 min to read. Allow extra time to work through examples.

Check a dataclass field every time code sets it, not only in the constructor, by putting a descriptor on the class.

This article uses Python. You can make it your practice mode to browse related content across the site.

An Order holds a quantity that must be a positive whole number. A check in __post_init__ catches a bad value when you build the order, but it does not run when code changes the field later:

from dataclasses import dataclass


@dataclass
class Order:
    quantity: int

    def __post_init__(self) -> None:
        if self.quantity <= 0:
            raise ValueError("quantity must be positive")


order = Order(2)
order.quantity = -5
print(order)  # Order(quantity=-5)

To run the check on every assignment, make the field a descriptor.

A descriptor runs code when you read or set an attribute

A descriptor is an object stored on a class. It runs code when you read or set the attribute of the same name on an instance. property and cached_property are descriptors. You write your own as a class with these methods:

  • __set_name__(owner, name) runs once, when Python creates the owner class, and tells the descriptor the attribute's name.
  • __get__(obj, objtype) runs when you read the attribute.
  • __set__(obj, value) runs when you assign to it.

Positive checks the value in __set__ and stores it under a private name, _quantity:

from dataclasses import dataclass


class Positive:
    def __set_name__(self, owner: type, name: str) -> None:
        self.storage_name = "_" + name

    def __get__(self, obj: object | None, objtype: type | None = None) -> int:
        if obj is None:
            raise AttributeError("no default")
        value: object = getattr(obj, self.storage_name)
        if not isinstance(value, int):
            raise TypeError("invalid stored quantity")
        return value

    def __set__(self, obj: object, value: int) -> None:
        if type(value) is not int:
            raise TypeError("quantity must be an integer")
        if value <= 0:
            raise ValueError("quantity must be positive")
        setattr(obj, self.storage_name, value)


@dataclass
class Order:
    quantity: Positive = Positive()


order = Order(quantity=2)
order.quantity = 3
print(order.quantity)  # 3
order.quantity = 0     # raises ValueError: quantity must be positive

Order(quantity=2) runs the generated __init__, which assigns self.quantity = 2. Because Order.quantity is a descriptor, Python calls Positive.__set__ instead of storing the value on the instance. So the same check runs in the constructor and on every later assignment. The check uses type(value) is not int so that it also rejects True, since bool is a subclass of int.

Raise AttributeError to make the field required

When @dataclass builds Order, it reads Order.quantity from the class to find the field's default. That read calls __get__(None, Order), with None because there is no instance. Positive raises AttributeError there, which tells the decorator that the field has no default. So Order() raises TypeError for the missing quantity. If __get__ returned a value instead, that value would become the default.

Keep that branch to one line. An AttributeError raised by mistake in longer code there would also make the field required.

Common mistakes

Adding slots=True. The decorator then builds a new class with a slot named quantity, and the slot replaces the descriptor. __set__ never runs, so Order(quantity=-1) succeeds. Leave slots=True off a class with descriptor fields.

Adding frozen=True. __set__ stores the value with an ordinary setattr, and a frozen class rejects that. Building an order raises FrozenInstanceError: cannot assign to field '_quantity'.

Expecting the field type to be int. The annotation is Positive, so fields(Order)[0].type is Positive. Code that converts values by field type, like the codec in Build a JSON Codec for Dataclasses, sees the descriptor class. mypy reads the signatures of __get__ and __set__ instead. It types order.quantity as int and rejects Order(quantity="x").

Use a descriptor when many fields share a rule

For one attribute with its own rule, a property with a setter is shorter. For a check that compares several fields when you build the object, use __post_init__. A descriptor is the better choice when the same rule applies to many fields, because you write it once and reuse it on each one.

Check your understanding You add slots=True to the dataclass Order above. What does Order(quantity=-1) do now? Show answer

It builds the order without an error. With slots=True, the decorator makes a new class with a slot named quantity, and the slot takes the descriptor's place. The generated __init__ writes straight to the slot, so Positive.__set__ never runs. Remove slots=True to get the check back.

Going further

  • Code that writes order._quantity directly skips the check, because the descriptor runs only for quantity.
  • SQLAlchemy and Django use descriptors for model attributes. That is how reading an attribute of a model object can run a database query.

The descriptor guide explains the protocol in full, and the descriptor-typed fields section of the dataclasses documentation covers the default rule.

step 21 of 28

Modelling Data with Dataclasses

Up next Convert and Validate a Port Number on Assignment