Python Custom Descriptor: Controlling Attribute Access
Learn how to build a python custom descriptor to control attribute access, validate values, and reuse logic across classes with the descriptor protocol.
When you need the same validation or transformation logic on attributes across multiple classes, repeating the code in every __init__ or property becomes tedious and error-prone. A python custom descriptor lets you encapsulate that behavior in one reusable object that intercepts attribute access at the class level. This article explains the descriptor protocol, shows how to implement a custom descriptor, and covers the practical tradeoffs you should consider before adopting them.
The Descriptor Protocol: get, set, and delete
A descriptor is any object that defines at least one of the special methods __get__, __set__, or __delete__. When a class attribute is a descriptor, Python replaces normal attribute access on instances with calls to these methods. The protocol is the foundation of properties, class methods, and static methods in Python.
__get__(self, instance, owner)is called when you access the attribute.instanceis the object you accessed it through, orNoneif accessed via the class.owneris the class itself.__set__(self, instance, value)is called when you assign to the attribute on an instance.__delete__(self, instance)is called when you usedelon the attribute.
A descriptor that implements only __get__ is a non-data descriptor. One that also implements __set__ or __delete__ is a data descriptor. This distinction affects attribute lookup precedence, as we'll see shortly.
A Minimal Custom Descriptor
Here is a simple descriptor that logs every access and assignment:
class LoggedAttribute: def __get__(self, instance, owner): if instance is None: return self print(f"Getting {self.name} from {instance}") return instance.__dict__.get(self.name) def __set__(self, instance, value): print(f"Setting {self.name} on {instance} to {value}") instance.__dict__[self.name] = value def __set_name__(self, owner, name): self.name = name
Use it in a class:
class Product: price = LoggedAttribute() def __init__(self, price): self.price = price p = Product(10) print(p.price)
When you assign self.price = price in __init__, Python calls LoggedAttribute.__set__, which stores the value in the instance dictionary. The __get__ method retrieves it. Without __set_name__, you would have to pass the attribute name explicitly to the descriptor constructor, which is awkward and error-prone.
Using set_name to Automate Attribute Names
The __set_name__ method was added in Python 3.6. It is called once when the class is created, with the class and the attribute name. This lets the descriptor know which attribute it is bound to, so you don't have to hardcode names. In the previous example, __set_name__ sets self.name, which is used in the logging messages and to access the instance dictionary.
If you are working with Python versions before 3.6, you must pass the name explicitly:
class Product: price = LoggedAttribute("price")
But with modern Python, __set_name__ is the preferred approach because it keeps the descriptor self-contained and avoids duplication.
Data vs Non-Data Descriptors and Attribute Precedence
In Python's attribute lookup, the order matters. When you access an attribute on an instance, Python first checks the class and its bases for a data descriptor. If found, it calls __get__. If not, it checks the instance dictionary. If the attribute is not there, it looks for a non-data descriptor in the class.
This means a data descriptor overrides the instance dictionary, while a non-data descriptor does not. A common use of non-data descriptors is to implement methods or read-only computed values that can be shadowed by instance attributes. For example, a function is a non-data descriptor because it defines __get__ but not __set__. That's why you can assign a method to an instance and shadow it.
Consider this non-data descriptor:
class ReadOnly: def __get__(self, instance, owner): return 42 class Demo: value = ReadOnly() d = Demo() print(d.value) # 42 d.value = 10 print(d.value) # 10, because instance dict takes precedence
If you want to prevent assignment, you need a data descriptor that raises an exception in __set__. This behavior is central to understanding how custom descriptors interact with normal attribute assignment.
Practical Example: Validated Attribute
A common use for a custom descriptor is to enforce validation rules. Instead of writing the same if checks in every setter, you can create a reusable validator:
class Validated: def __init__(self, validator): self.validator = validator self.name = None def __set_name__(self, owner, name): self.name = name def __get__(self, instance, owner): if instance is None: return self return instance.__dict__[self.name] def __set__(self, instance, value): self.validator(value) instance.__dict__[self.name] = value def positive(value): if value <= 0: raise ValueError("must be positive") class Order: quantity = Validated(positive) def __init__(self, quantity): self.quantity = quantity
Now any assignment to quantity goes through positive. This keeps the validation logic in one place and makes it easy to reuse across multiple classes. You can also pass a callable that returns a boolean or raises an exception, depending on your preference.
Common Pitfalls and Mistakes
Writing a custom descriptor involves a few subtle traps that can break your code in confusing ways.
Forgetting to handle instance is None in __get__. When you access the descriptor from the class, like Order.quantity, Python passes instance = None. If your __get__ tries to access instance.__dict__, it will raise AttributeError. Always check for None and return the descriptor itself or a sensible default.
Storing values on the descriptor instead of the instance. If you store the value in self (the descriptor), it will be shared across all instances of the class. That is rarely what you want. Use instance.__dict__ to store per-instance data, as shown above.
Using a mutable default in the descriptor. If your descriptor needs a default value, do not use a mutable object like a list or dict as a class-level default. The same object would be shared across instances. Instead, initialize the value in __set__ or use a sentinel.
Forgetting __set_name__ in older Python. If you support Python 3.5 or earlier, __set_name__ does not exist. You must pass the name manually, or use a metaclass to set it.
When to Use Descriptors vs Alternatives
Descriptors are powerful, but they are not always the right tool. Here is a comparison with common alternatives:
| Approach | Best for | Tradeoff |
|---|---|---|
| Property | Single attribute on one class | Repetitive if used across many classes |
| Descriptor | Reusable attribute logic across classes | More boilerplate, requires understanding protocol |
| getattr | Dynamic attribute handling, fallback behavior | Not called for existing attributes, can be slow |
| Metaclass | Modifying class creation, not individual attrs | Complex, hard to reason about |
Use a descriptor when you find yourself copying the same property implementation into several classes. For a single attribute, a property is simpler and more readable. If you need to intercept access to attributes that may not exist, __getattr__ is more appropriate, but it has different semantics.
Performance and Maintainability Considerations
Descriptors add a layer of indirection on every attribute access. The overhead is small but not zero. In performance-critical code, measure whether the extra function calls matter. For most applications, the readability and reuse benefits outweigh the cost.
Maintainability also depends on how you structure the descriptor. Keep the descriptor focused on one responsibility. If it does too much, it becomes hard to test and debug. Consider writing unit tests for the descriptor in isolation, so you can verify its behavior without the classes that use it.
Another consideration is compatibility. __set_name__ requires Python 3.6+. If you must support older versions, you need a workaround. Also, descriptors interact with inheritance and slots in ways that can surprise you. For example, if a class uses __slots__, the instance dictionary is not available, so you must store data elsewhere, such as in the descriptor keyed by the instance.
Advanced Pattern: Cached Descriptor
A useful advanced pattern is a descriptor that caches a computed value on the instance. This is similar to functools.cached_property, but you can customize the caching behavior.
class CachedAttribute: def __init__(self, func): self.func = func self.name = None def __set_name__(self, owner, name): self.name = name def __get__(self, instance, owner): if instance is None: return self if self.name not in instance.__dict__: instance.__dict__[self.name] = self.func(instance) return instance.__dict__[self.name] class Report: def __init__(self, data): self.data = data @CachedAttribute def summary(self): # Expensive computation return sum(self.data) / len(self.data)
This descriptor computes the value once and stores it in the instance dictionary. Subsequent accesses skip the computation. This pattern is especially useful when the computation is expensive and the result does not change during the object's lifetime.
Remember that this is a non-data descriptor, so assigning to summary on an instance will overwrite the cached value. If you want to prevent that, add a __set__ that raises an error.
Custom descriptors give you precise control over attribute access. They are a core part of Python's metaprogramming toolkit, and understanding them helps you write cleaner, more maintainable code when you need to reuse attribute logic across classes.