Python Descriptor Protocol: How to Control Attribute Access
python descriptor protocol: Understand the Python descriptor protocol to control attribute access, implement validation, and build reusable attribute logic with __get_...
The python descriptor protocol is a low-level mechanism that controls how attribute access works on objects. When you define a class that implements __get__, __set__, or __delete__, instances of that class become descriptors that can intercept attribute operations on another class. This protocol underpins many standard Python features, including property, classmethod, and staticmethod. Understanding it lets you build reusable attribute logic that goes beyond what a simple property can offer.
The Role of Descriptors in Attribute Access
In Python, attribute access on an instance is resolved by first checking the class for a descriptor. If the class attribute is a descriptor, Python calls the appropriate descriptor method instead of directly reading or writing the instance's __dict__. This happens transparently, so the caller sees normal attribute syntax. For example, when you write obj.attr, Python looks up attr on the type of obj. If that lookup returns a descriptor, Python calls __get__ with the instance and the class. Similarly, obj.attr = value triggers __set__, and del obj.attr triggers __delete__.
Descriptors are always defined as class attributes, never as instance attributes. The descriptor object itself lives on the class, and its methods receive the instance being accessed. This separation allows the descriptor to manage state per instance, typically by storing values in the instance's __dict__ under a private key.
The Three Core Methods: get, set, and delete
A descriptor can implement any of three methods. __get__ handles attribute reads, __set__ handles writes, and __delete__ handles deletion. The signatures are:
class Descriptor: def __get__(self, obj, objtype=None): ... def __set__(self, obj, value): ... def __delete__(self, obj): ...
obj is the instance on which the attribute is accessed. For __get__, objtype is the owning class. When the attribute is accessed on the class itself (e.g., MyClass.attr), obj is None. A common pattern is to return the descriptor itself in that case, which is what property does.
Here is a minimal descriptor that validates positive integers:
class PositiveNumber: def __set_name__(self, owner, name): self.private_name = "_" + name def __get__(self, obj, objtype=None): if obj is None: return self return getattr(obj, self.private_name) def __set__(self, obj, value): if value <= 0: raise ValueError("value must be positive") setattr(obj, self.private_name, value) def __delete__(self, obj): delattr(obj, self.private_name)
This descriptor stores the actual value in the instance's __dict__ under a name prefixed with an underscore. The __set_name__ method, explained later, gives the descriptor the attribute name it is assigned to, so it can construct the private key without hardcoding.
Data Descriptors vs Non-Data Descriptors
The presence of __set__ or __delete__ determines whether a descriptor is a data descriptor. A descriptor that defines only __get__ is a non-data descriptor. This distinction affects attribute lookup precedence. When an instance has an attribute in its __dict__ with the same name as a class-level descriptor, the data descriptor takes precedence over the instance dictionary. A non-data descriptor is shadowed by an instance attribute.
| Descriptor type | Defines set or delete? | Precedence over instance dict |
|---|---|---|
| Data descriptor | Yes | Yes |
| Non-data descriptor | No | No |
This behavior is why property (a data descriptor) always overrides an instance attribute, while a plain method (a non-data descriptor) can be overridden by assigning a function to an instance. The standard library relies on this distinction in several places, such as functools.cached_property, which is a non-data descriptor so that the computed value can be stored in the instance __dict__ and shadow the descriptor on subsequent accesses.
Using set_name to Bind Descriptor Names
When a descriptor is assigned as a class attribute, Python calls __set_name__ on it with the owning class and the attribute name. This hook was added in Python 3.6 and is essential for building reusable descriptors that need to know which attribute they are managing. Without it, you would have to pass the name explicitly when creating the descriptor, which is verbose and error-prone.
class Descriptor: def __set_name__(self, owner, name): self.name = name
The owner argument is the class where the descriptor is assigned. In the PositiveNumber example, __set_name__ builds a private key by prefixing the attribute name with an underscore. This avoids collisions with other instance attributes and makes the descriptor work regardless of the attribute name.
Practical Use Cases: Validation and Type Checking
Descriptors are ideal for enforcing invariants across multiple attributes. Instead of writing a separate property for each field, you can define one descriptor and reuse it. For example, a PositiveNumber descriptor can be used on any numeric attribute that must stay positive:
class Order: quantity = PositiveNumber() price = PositiveNumber() def __init__(self, quantity, price): self.quantity = quantity self.price = price
When you assign self.quantity = quantity, the descriptor's __set__ runs and validates the value. This keeps validation logic in one place and prevents the same checks from being duplicated across constructors or setters. You can extend the pattern to type checking, range checks, or any custom constraint.
Another common use is to create read-only attributes by implementing only __get__ and raising an error in __set__. This is essentially what property does when you omit a setter, but a custom descriptor gives you more control over the error message and the underlying storage.
Computed Attributes and Caching with Descriptors
Descriptors can compute a value on first access and cache it for later. A classic example is a cached property that avoids recomputing an expensive result. Because non-data descriptors are shadowed by instance attributes, you can store the computed value in the instance __dict__ after the first __get__ call.
class CachedProperty: def __init__(self, func): self.func = func self.name = func.__name__ def __get__(self, obj, objtype=None): if obj is None: return self value = self.func(obj) setattr(obj, self.name, value) return value
When the attribute is accessed for the first time, the descriptor computes the value and stores it in the instance __dict__. On subsequent accesses, the instance dictionary contains the attribute, so the descriptor is no longer consulted. This is the same pattern used by functools.cached_property in the standard library.
Data descriptors can also cache values, but they need to store the cache in a separate location because they always take precedence over the instance dictionary. A common approach is to use a per-instance cache dictionary stored in the instance __dict__ under a private key.
Performance and Runtime Considerations
Descriptor calls add a small overhead compared to direct attribute access. Every read, write, or delete goes through a Python method call, which is slower than a simple dictionary lookup. For most applications this cost is negligible, but in tight loops or performance-critical code it can matter. The protocol itself is implemented in C and is used throughout the standard library, so the overhead is not excessive. If you need maximum speed, you can use __slots__ to avoid descriptors entirely for simple attributes, or you can avoid descriptors for attributes that are accessed millions of times per second.
Another runtime consideration is that descriptors are looked up on the class, not the instance. This means that if you assign a descriptor to an instance, it will not work as expected. The descriptor must be a class attribute to be recognized by the protocol. Also, when using inheritance, descriptors defined on a parent class are inherited and work on subclasses, but __set_name__ is called only once when the descriptor is assigned in the parent class body, not for each subclass. This can lead to subtle bugs if the descriptor relies on the attribute name and is used in multiple inheritance hierarchies.
Common Pitfalls and Maintainability
One of the most common mistakes is storing state directly on the descriptor instance. Because the descriptor is a class attribute, any value stored on self is shared across all instances of the class. The PositiveNumber example avoids this by storing the value in the instance's __dict__ using a private key. Always store per-instance data in the instance, not on the descriptor.
Another pitfall is forgetting to call __set_name__ or relying on it in a class that is created dynamically. When a class is built with type(), __set_name__ is called automatically, but if you manually assign a descriptor to a class after creation, you must call it yourself. This is rarely needed, but it is worth knowing.
Maintainability suffers when descriptors are overused. A simple property is often clearer than a custom descriptor, especially if the logic is only needed in one place. Descriptors shine when you have a repeated pattern across many attributes or classes. Before writing a descriptor, ask whether a property, a method, or a simple attribute would suffice. Descriptors add indirection, and too much indirection makes code harder to follow.
Finally, be aware that descriptors interact with other Python features such as __slots__, __getattribute__, and pickle. For instance, if a class uses __slots__, instance attributes are stored in slots rather than a __dict__, and a descriptor that tries to use setattr(obj, name, value) may fail if the slot name does not match. Testing descriptors in combination with these features is essential before using them in a library or framework.