Ever needed to control how a Python class manages its attributes? Today, in this tutorial, we will explore Python Descriptors, one of Python’s one of most powerful yet underappreciated advanced features. They’re powerful, a bit sneaky, and will totally change how you handle attribute access in classes. Descriptors are absolutely game-changing once you understand them!
Python Descriptors are objects that implement a specific protocol to customize how attribute access works. It lets objects control what happens when attributes are accessed or modified.
Think of them as the ultimate middleman—controlling the flow between your code and the attribute. They’re the magic behind many Python features you already use – from properties and methods to the entire Django ORM system! 🐍✨
You create a descriptor by building a class with at least one of these special methods:
__get__(self, instance, owner): Fires when you grab an attribute (like obj.attr).__set__(self, instance, value): Kicks in when you assign a value (like obj.attr = 5).__delete__(self, instance): Handles attribute deletion (like del obj.attr).Implementing these methods gives you incredible control over how attributes behave when accessed, modified, or deleted. This is powerful stuff!
Python Descriptors aren’t just cool but also essential for writing top-notch code. Here’s why they rock:
The first time I implemented a custom descriptor in a system, the codebase shrunk significantly while becoming more maintainable. Descriptors are that powerful!
Let’s start with a basic example – a descriptor that validates values:
class Positive:
def __init__(self, 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):
if value <= 0:
raise ValueError(f"{self.name} must be positive")
instance.__dict__[self.name] = value
class Product:
price = Positive("price")
quantity = Positive("quantity")
def __init__(self, price, quantity):
self.price = price
self.quantity = quantity
def total_cost(self):
return self.price * self.quantity
# This works fine
product = Product(10, 5)
print(product.total_cost()) # 50
# This raises a ValueError
try:
product.price = -10
except ValueError as e:
print(e) # price must be positiveCode language: HTML, XML (xml) Now that we saw the example, did you noticed what happened there? Our Positive descriptor enforces that values must be positive, handling the validation logic in one place. I’ve used this pattern countless times to make my code more robust and maintainable.
Let’s break down how the descriptor protocol actually works:
__get__(self, instance, owner): Called when the attribute is accessed self: The descriptor objectinstance: The instance owning the attribute (or None for class access)owner: The class where the descriptor is defined__set__(self, instance, value): Called when the attribute is assigned self: The descriptor objectinstance: The instance owning the attributevalue: The value being assigned__delete__(self, instance): Called when the attribute is deleted self: The descriptor objectinstance: The instance owning the attributeA descriptor that implements __get__ but not __set__ is a non-data descriptor, while one that implements both is a data descriptor. This distinction matters because data descriptors always take precedence over instance dictionary entries.
Here’s a flow chart diagram for showing how accessing obj.attr triggers a descriptor’s methods:
Data descriptors implement both __get__() and either __set__() or __delete__(). They have higher precedence in attribute lookup and can control both getting and setting of attributes. Examples include properties and most built-in descriptors.
Non-data descriptors implement only __get__(). They have lower precedence and only control attribute retrieval, not assignment. The most common example is functions (which become bound methods when accessed on instances).
The precedence difference is crucial: when looking up an attribute, Python checks data descriptors first, then instance dictionary, then non-data descriptors, then class dictionary. This means data descriptors can override instance attributes, while non-data descriptors can be shadowed by instance attributes.
For example, a property (data descriptor) will always be called even if you try to set an instance attribute with the same name, while a method (non-data descriptor) can be overridden by setting an instance attribute.
Descriptors can be used in so many situations, but here are some of my favorites:
class TypedAttribute:
def __init__(self, name, expected_type):
self.name = name
self.expected_type = expected_type
def __get__(self, instance, owner):
if instance is None:
return self
return instance.__dict__.get(self.name, None)
def __set__(self, instance, value):
if not isinstance(value, self.expected_type):
raise TypeError(f"{self.name} must be of type {self.expected_type.__name__}")
instance.__dict__[self.name] = value
class Person:
name = TypedAttribute("name", str)
age = TypedAttribute("age", int)
def __init__(self, name, age):
self.name = name
self.age = age class LazyProperty:
def __init__(self, function):
self.function = function
self.name = function.__name__
def __get__(self, instance, owner):
if instance is None:
return self
value = self.function(instance)
instance.__dict__[self.name] = value # Cache the result
return value
class DataProcessor:
def __init__(self, data):
self.data = data
@LazyProperty
def processed_data(self):
print("Processing data...") # Expensive operation
return [x * 2 for x in self.data] Using such a pattern in a data processing pipeline could cut processing time significantly due to no longer recomputing values unnecessarily. 🚀
class LoggingDescriptor:
def __init__(self, name):
self.name = name
def __get__(self, instance, owner):
if instance is None:
return self
print(f"Accessing {self.name}")
return instance.__dict__[self.name]
def __set__(self, instance, value):
print(f"Setting {self.name} to {value}")
instance.__dict__[self.name] = value
class MyClass:
attr = LoggingDescriptor('attr')
obj = MyClass()
obj.attr = 10 # Setting attr to 10
print(obj.attr) # Accessing attr, then 10 We will explore more real-world use-cases later in the tutorial.
I’ve made my share of mistakes with descriptors! Here are the typically the most common ones:
Using instance attributes with the same name as the descriptor: This leads to infinite recursion! Always store the value under a different name:
# BAD - leads to recursion
def __set__(self, instance, value):
instance.name = value # This calls __set__ again!
# GOOD - use different name or __dict__
def __set__(self, instance, value):
instance.__dict__["_name"] = valueCode language: PHP (php) Forgetting to handle the class access case: When a descriptor is accessed on the class rather than an instance, instance will be None:
# Don't forget to handle this case
def __get__(self, instance, owner):
if instance is None:
return self # Return the descriptor itself
# Normal instance access handlingCode language: PHP (php) Not understanding descriptor priority As mentioned previously once, data descriptors (with __set__) take precedence over instance dictionary entries, while non-data descriptors don’t. This subtle distinction has caused me HOURS of debugging!
Besides the fundamentals we have covered so far, there’s more to to it. Once you’re comfortable with basic descriptors, you can use them for some truly powerful patterns:
To begin with, did you know that Python functions are non-data descriptors? That’s how methods work:
class Function:
def __get__(self, instance, owner):
if instance is None:
return self
return MethodType(self, instance) # Bind the method to the instance Combine descriptors with Python metaclasses for even more control:
class ModelMeta(type):
def __new__(mcs, name, bases, namespace):
fields = {
key: value for key, value in namespace.items()
if isinstance(value, Field)
}
for name, field in fields.items():
field.name = name
namespace['_fields'] = fields
return super().__new__(mcs, name, bases, namespace)
class Model(metaclass=ModelMeta):
pass
class Field:
def __init__(self):
self.name = None
def __get__(self, instance, owner):
if instance is None:
return self
return instance.__dict__.get(self.name)
def __set__(self, instance, value):
instance.__dict__[self.name] = value I used this pattern to build a mini-ORM, which worked beautifully to simplify database access. Sometimes I wonder – did I really need to learn SQLAlchemy? 😂
To illustrate it’s power, let me share a few real-world examples where descriptors can save the day:
While there are so many benefits, Descriptors aren’t perfect. They’ve got quirks:
Also, descriptors trigger on instance access, not class access—unless you handle it. It took me a minute to understand that.
As we explained in this tutorial guide, Python descriptors represent one of the language’s most elegant design patterns. They’ve completely changed how I approach attribute management and API design.
Once you understand descriptors, you’ll start seeing opportunities to use them everywhere. They’ll help you write cleaner, more maintainable code while giving you fine-grained control over object behaviour. Read the Official docs as well.
So, are you ready to take your Python skills to the next level? Start experimenting with descriptors today – I promise your future self will thank you! 💯
Learn python file handling from scratch! This comprehensive guide walks you through reading, writing, and managing files in Python with real-world examples, troubleshooting tips, and…
You've conquered the service worker lifecycle, mastered caching strategies, and explored advanced features. Now it's time to lock down your implementation with battle-tested service worker…
Unlock the full potential of service workers with advanced features like push notifications, background sync, and performance optimization techniques that transform your web app into…
This website uses cookies.