Descriptors in Python — The Ultimate Guide

I am a Tech Enthusiast having 13+ years of experience in 𝐈𝐓 as a 𝐂𝐨𝐧𝐬𝐮𝐥𝐭𝐚𝐧𝐭, 𝐂𝐨𝐫𝐩𝐨𝐫𝐚𝐭𝐞 𝐓𝐫𝐚𝐢𝐧𝐞𝐫, 𝐌𝐞𝐧𝐭𝐨𝐫, with 12+ years in training and mentoring in 𝐒𝐨𝐟𝐭𝐰𝐚𝐫𝐞 𝐄𝐧𝐠𝐢𝐧𝐞𝐞𝐫𝐢𝐧𝐠, 𝐃𝐚𝐭𝐚 𝐄𝐧𝐠𝐢𝐧𝐞𝐞𝐫𝐢𝐧𝐠, 𝐓𝐞𝐬𝐭 𝐀𝐮𝐭𝐨𝐦𝐚𝐭𝐢𝐨𝐧 𝐚𝐧𝐝 𝐃𝐚𝐭𝐚 𝐒𝐜𝐢𝐞𝐧𝐜𝐞. I have 𝒕𝒓𝒂𝒊𝒏𝒆𝒅 𝒎𝒐𝒓𝒆 𝒕𝒉𝒂𝒏 10,000+ 𝑰𝑻 𝑷𝒓𝒐𝒇𝒆𝒔𝒔𝒊𝒐𝒏𝒂𝒍𝒔 and 𝒄𝒐𝒏𝒅𝒖𝒄𝒕𝒆𝒅 𝒎𝒐𝒓𝒆 𝒕𝒉𝒂𝒏 500+ 𝒕𝒓𝒂𝒊𝒏𝒊𝒏𝒈 𝒔𝒆𝒔𝒔𝒊𝒐𝒏𝒔 in the areas of 𝐒𝐨𝐟𝐭𝐰𝐚𝐫𝐞 𝐃𝐞𝐯𝐞𝐥𝐨𝐩𝐦𝐞𝐧𝐭, 𝐃𝐚𝐭𝐚 𝐄𝐧𝐠𝐢𝐧𝐞𝐞𝐫𝐢𝐧𝐠, 𝐂𝐥𝐨𝐮𝐝, 𝐃𝐚𝐭𝐚 𝐀𝐧𝐚𝐥𝐲𝐬𝐢𝐬, 𝐃𝐚𝐭𝐚 𝐕𝐢𝐬𝐮𝐚𝐥𝐢𝐳𝐚𝐭𝐢𝐨𝐧𝐬, 𝐀𝐫𝐭𝐢𝐟𝐢𝐜𝐢𝐚𝐥 𝐈𝐧𝐭𝐞𝐥𝐥𝐢𝐠𝐞𝐧𝐜𝐞 𝐚𝐧𝐝 𝐌𝐚𝐜𝐡𝐢𝐧𝐞 𝐋𝐞𝐚𝐫𝐧𝐢𝐧𝐠. I am interested in 𝐰𝐫𝐢𝐭𝐢𝐧𝐠 𝐛𝐥𝐨𝐠𝐬, 𝐬𝐡𝐚𝐫𝐢𝐧𝐠 𝐭𝐞𝐜𝐡𝐧𝐢𝐜𝐚𝐥 𝐤𝐧𝐨𝐰𝐥𝐞𝐝𝐠𝐞, 𝐬𝐨𝐥𝐯𝐢𝐧𝐠 𝐭𝐞𝐜𝐡𝐧𝐢𝐜𝐚𝐥 𝐢𝐬𝐬𝐮𝐞𝐬, 𝐫𝐞𝐚𝐝𝐢𝐧𝐠 𝐚𝐧𝐝 𝐥𝐞𝐚𝐫𝐧𝐢𝐧𝐠 new subjects.
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.


