Python Descriptors: Building Property-Like Behavior from Scratch
Learn how Python descriptors work under the hood, build your own property-like behavior, and understand the secret sauce behind @property.
Have you ever wondered how @property actually works under the hood in Python? It's not magic—it's descriptors. Understanding descriptors is like learning the secret ingredient that makes Python's attribute system so flexible and powerful.
When I first started tinkering with Python, I thought @property was just some built-in decorator magic that I'd never fully understand. But then I discovered descriptors, and everything clicked. Let me show you how they work and how you can build your own.
What Exactly is a Descriptor?
A descriptor is any Python object that implements at least one of these methods: __get__, __set__, or __delete__. When you access an attribute on an object, Python checks if that attribute has a descriptor protocol—if it does, the descriptor's methods take over.
Think of it like this: normally, when you write obj.attribute, Python just looks up attribute in obj.__dict__. But if attribute is a descriptor, Python instead calls attribute.__get__(obj, type(obj)) and returns whatever that method gives you.
Building Your First Descriptor
Let's start with something practical. At Pythonskillset, we often need validated attributes—like ensuring an email address has an @ symbol or a username is long enough. Here's a simple validator descriptor:
class ValidatedAttribute:
def __init__(self, validator):
self.validator = validator
self.data = {}
def __get__(self, obj, objtype=None):
if obj is None:
return self
return self.data.get(id(obj), None)
def __set__(self, obj, value):
self.validator(value)
self.data[id(obj)] = value
Now let's use it:
def validate_email(email):
if '@' not in email:
raise ValueError("Invalid email address")
class User:
email = ValidatedAttribute(validate_email)
def __init__(self, name, email):
self.name = name
self.email = email # Validation happens here
# This works fine
user1 = User("Alice", "alice@pythonskillset.com")
# This raises ValueError
user2 = User("Bob", "bob_at_example.com") # Error!
Notice something? Each instance gets its own storage (we used id(obj) as a key). That's because descriptors live on the class, not on instances. Without this technique, all instances would share the same value—which is almost never what you want.
The Magic Behind @property
Now that you've seen a basic descriptor, let's recreate @property from scratch. It's surprisingly simple:
class MyProperty:
def __init__(self, fget=None, fset=None, fdel=None, doc=None):
self.fget = fget
self.fset = fset
self.fdel = fdel
self.__doc__ = doc or fget.__doc__
def __get__(self, obj, objtype=None):
if obj is None:
return self
if self.fget is None:
raise AttributeError("unreadable attribute")
return self.fget(obj)
def __set__(self, obj, value):
if self.fset is None:
raise AttributeError("can't set attribute")
self.fset(obj, value)
def __delete__(self, obj):
if self.fdel is None:
raise AttributeError("can't delete attribute")
self.fdel(obj)
def getter(self, fget):
return type(self)(fget, self.fset, self.fdel, self.__doc__)
def setter(self, fset):
return type(self)(self.fget, fset, self.fdel, self.__doc__)
def deleter(self, fdel):
return type(self)(self.fget, self.fset, fdel, self.__doc__)
And here's how you'd use it:
class Circle:
def __init__(self, radius):
self._radius = radius
@MyProperty
def radius(self):
return self._radius
@radius.setter
def radius(self, value):
if value < 0:
raise ValueError("Radius cannot be negative")
self._radius = value
c = Circle(5)
print(c.radius) # 5
c.radius = 10 # Works fine
c.radius = -3 # Raises ValueError
Descriptors in the Real World
You've actually been using descriptors without realizing it. Remember how staticmethod and classmethod work? They're both descriptors. When you call a static method, the descriptor returns the raw function. When you call a class method, it binds the class as the first argument.
Here's a real-world example from Pythonskillset's analytics system. We needed to track how many times certain methods were called:
class CallTracker:
def __init__(self, func):
self.func = func
self.count = 0
def __get__(self, obj, objtype=None):
if obj is None:
return self
def wrapper(*args, **kwargs):
self.count += 1
print(f"{self.func.__name__} called {self.count} times")
return self.func(obj, *args, **kwargs)
return wrapper
class DataProcessor:
@CallTracker
def process(self, data):
return [d * 2 for d in data]
dp = DataProcessor()
dp.process([1, 2, 3]) # Prints: process called 1 times
dp.process([4, 5, 6]) # Prints: process called 2 times
When Should You Use Descriptors?
Descriptors aren't something you'll use every day, but they're invaluable when you need:
- Reusable attribute logic - Like validation that you want across multiple classes
- Property-like behavior with shared code - When
@propertywould mean repeating yourself - Managing cross-cutting concerns - Like logging, caching, or access control on attributes
A good rule of thumb: if you find yourself writing the same getter/setter pattern across different classes, it's time to consider a descriptor.
Common Pitfalls to Avoid
-
Forgetting instance-specific storage - Always use a dictionary keyed by
id(obj)or store data in the instance's__dict__instead of on the descriptor itself. -
Not handling
obj is None- When you access the descriptor from the class (likeMyClass.my_descriptor),objwill beNone. Python expects you to returnselfin that case. -
Overcomplicating things - If a simple
@propertyworks, use it. Descriptors are for when you need reusable, generic attribute behavior.
Wrapping Up
Descriptors are one of those Python features that seem intimidating at first but become incredibly elegant once you understand them. They're the foundation for properties, classmethods, staticmethods, and many other Python internals.
Next time you use @property, remember that you're standing on the shoulders of the descriptor protocol. And now you have the power to create your own attribute behavior that's just as clean and reusable.
What's your next project at Pythonskillset going to be? Maybe a descriptor that auto-caches expensive computations, or one that logs every attribute change? The possibilities are endless once you understand this fundamental pattern.
Comments
Questions, corrections, and tips stay visible for everyone reading this page.
Join the discussion
No comments yet
Be the first to leave a note — it helps the next reader.