Descriptors in Python — The Ultimate Guide

Introduction
In Python, everything is an object, and attribute access is a fundamental operation. But what if you wanted to control how an attribute behaves when accessed, modified, or deleted? That’s where descriptors come in. They’re a powerful, often overlooked feature that let you manage attribute access dynamically within a class.
This guide provides a complete overview of descriptors, including how they work, when to use them, and best practices for clean, reusable implementations.
What Are Descriptors?
A descriptor is a Python object that implements one or more of the following special methods:
__get__(self, instance, owner): Called when the attribute is accessed.__set__(self, instance, value): Called when the attribute is assigned a new value.__delete__(self, instance): Called when the attribute is deleted withdel.
How Attribute Lookup Works
When an attribute is accessed in Python, the interpreter follows a specific lookup order:
Instance dictionary (
__dict__) → If the attribute exists in the instance, Python retrieves it directly.Class attributes → If not found in the instance, Python checks the class definition for the attribute.
Descriptors → If the attribute is a descriptor, Python invokes its special methods (
__get__,__set__,__delete__).Inheritance chain → If still unresolved, Python searches in parent classes.
Fallback → If the attribute is missing everywhere, Python raises an
AttributeError.
Thus, descriptors override instance attributes under certain conditions, making them extremely powerful for fine-grained control over attribute behavior.
Why Use Descriptors?
Descriptors provide precise control over attribute access, enabling:
Validation and type-checking → Ensure values meet specific criteria.
Computed properties → Define attributes that calculate values dynamically.
Resource management → Handle opening/closing connections, caching values.
Access control → Implement authentication and logging for sensitive attributes.
Lazy evaluation → Defer expensive computations until needed.
Built-in Python Features Using Descriptors
Several core Python features use descriptors internally:
property()→ Implements computed attributes.staticmethod()&classmethod()→ Modify method behavior at the class level.functools.cached_property→ Implements efficient caching.
The Descriptor Protocol Methods
__get__(self, instance, owner) → Handles attribute retrieval
Called when the attribute is accessed on an instance.
class MyDescriptor:
def __get__(self, instance, owner):
print("Getting value")
return instance._value # Return stored value
class MyClass:
value = MyDescriptor()
obj = MyClass()
obj.value # Calls __get__()
__set__(self, instance, value) → Handles attribute assignment
Called when an attribute is set. Often used for validation or transformations.
class MyDescriptor:
def __set__(self, instance, value):
if value < 0:
raise ValueError("Negative value not allowed")
print("Setting value")
instance._value = value # Store validated value
class MyClass:
value = MyDescriptor()
obj = MyClass()
obj.value = 10 # Calls __set__()
__delete__(self, instance) → Handles attribute deletion
Called when an attribute is deleted using del.
class MyDescriptor:
def __delete__(self, instance):
print("Deleting value")
del instance._value # Remove stored value
class MyClass:
value = MyDescriptor()
obj = MyClass()
del obj.value # Calls __delete__()
Data vs Non-Data Descriptors
Data descriptors → Implement both
__get__and (__set__or__delete__).Non-data descriptors → Implement only
__get__.
Data descriptors have higher precedence than instance attributes, while non-data descriptors do not.
class NonData:
def __get__(self, instance, owner):
return "Non-data descriptor"
class Data:
def __get__(self, instance, owner):
return "Data descriptor"
def __set__(self, instance, value):
pass
class Example:
nd = NonData()
d = Data()
e = Example()
e.nd = "Instance attribute"
print(e.nd) # 'Instance attribute' because NonData is non-data descriptor
print(e.d) # 'Data descriptor' because Data takes precedence
How Python’s Built-ins Use Descriptors
property() example
The property() function creates a descriptor internally, which manages attribute access.
class Circle:
def __init__(self, radius):
self._radius = radius
def get_radius(self):
return self._radius
def set_radius(self, value):
if value < 0:
raise ValueError("Negative radius not allowed")
self._radius = value
radius = property(get_radius, set_radius)
c = Circle(5)
print(c.radius) # Calls get_radius()
c.radius = 10 # Calls set_radius()
Real-World Use Cases for Descriptors
1. Validation → Ensure values meet requirements
class PositiveNumber:
def __set__(self, instance, value):
if value < 0:
raise ValueError("Only positive values allowed")
instance._value = value
class Account:
balance = PositiveNumber()
a = Account()
a.balance = -100 # Raises ValueError
2. Caching → Store expensive computations
class CachedProperty:
def __get__(self, instance, owner):
if "_cached_value" not in instance.__dict__:
instance._cached_value = instance.expensive_computation()
return instance._cached_value
class MyClass:
cache = CachedProperty()
def expensive_computation(self):
print("Running expensive computation...")
return 42
obj = MyClass()
print(obj.cache) # Runs computation once
print(obj.cache) # Returns cached value
3. Logging and Debugging → Track attribute changes
class LoggingDescriptor:
def __set__(self, instance, value):
print(f"Setting {instance.__class__.__name__}.value to {value}")
instance._value = value
class MyClass:
value = LoggingDescriptor()
obj = MyClass()
obj.value = 100 # Logs assignment
Best Practices
Use descriptors when multiple classes need shared attribute logic.
Keep descriptor classes modular and reusable.
Use
property()for simpler cases.Avoid excessive complexity—only use descriptors when truly necessary.
Limitations
Increased complexity → Can make code harder to debug.
Performance impact → Descriptors add a level of indirection in attribute lookup.
Not always necessary →
property()is often sufficient.
Conclusion
Descriptors are one of Python’s most powerful mechanisms for controlling attribute behavior. They are used internally in Python for built-in features like property(), and they provide fine-grained control over attribute access.
By mastering descriptors, you gain a deeper understanding of Python internals—preparing you to build robust frameworks, APIs, and high-performance applications.



