Timothy was reviewing a colleague’s code when he stopped at an unfamiliar pattern. “Margaret, what’s this @property decorator doing? Look at this - it’s a method, but you call it without parentheses like it’s just a regular attribute. I’ve never seen anything like it.”
Margaret leaned over to look at his screen. “Ah, you’ve stumbled onto one of Python’s most elegant secrets, Timothy. That, my friend, is the descriptor protocol in action.”
“Descriptor protocol?” Timothy frowned, intrigued.
“Most people haven’t,” Margaret said, settling into the chair next to him. “But you use it every single day. It’s how @property works, how @classmethod and @staticmethod work, and even how regular methods bind to instances. It’s the mechanism that makes Django’s model fields feel like…
Timothy was reviewing a colleague’s code when he stopped at an unfamiliar pattern. “Margaret, what’s this @property decorator doing? Look at this - it’s a method, but you call it without parentheses like it’s just a regular attribute. I’ve never seen anything like it.”
Margaret leaned over to look at his screen. “Ah, you’ve stumbled onto one of Python’s most elegant secrets, Timothy. That, my friend, is the descriptor protocol in action.”
“Descriptor protocol?” Timothy frowned, intrigued.
“Most people haven’t,” Margaret said, settling into the chair next to him. “But you use it every single day. It’s how @property works, how @classmethod and @staticmethod work, and even how regular methods bind to instances. It’s the mechanism that makes Django’s model fields feel like magic.”
Timothy’s eyes widened. “Wait - all of those things use the same underlying system?”
“Exactly. Let me show you the magic behind the curtain.” Margaret pulled up a fresh editor window. “But first, let’s look at what’s confusing you.”
The Puzzle: Methods That Act Like Attributes
Timothy showed Margaret the code that had stopped him in his tracks:
class Temperature:
def __init__(self, celsius):
self._celsius = celsius
@property
def fahrenheit(self):
"""This is a METHOD, but you access it like an ATTRIBUTE!"""
return self._celsius * 9/5 + 32
@fahrenheit.setter
def fahrenheit(self, value):
"""This is called when you ASSIGN to fahrenheit"""
self._celsius = (value - 32) * 5/9
# Usage - no parentheses!
temp = Temperature(0)
print(temp.fahrenheit) # 32.0 - called the method without ()!
temp.fahrenheit = 98.6 # Assignment triggers the setter!
print(temp.celsius) # 37.0 - setter converted and stored
Timothy stared at the code, then ran it. When the output appeared, he shook his head in disbelief.
“Okay, that’s just weird,” he said. “We’re calling fahrenheit without parentheses - temp.fahrenheit, not temp.fahrenheit() - but it’s clearly running that method. And then when we assign to it with temp.fahrenheit = 98.6, somehow the setter method gets called. How does Python know to do this?”
Margaret grinned. “That’s exactly the right question. Most people just accept that @property works and never ask how. But you’re ready for the truth.”
“Which is?”
“That there’s a protocol - a contract between Python and your objects - that controls what happens when you access, set, or delete attributes. It’s called the descriptor protocol, and it’s surprisingly simple once you see it.”
Timothy leaned forward. “I’m listening.”
The Descriptor Protocol: Python’s Attribute Access Magic
Margaret opened a new code window. “Okay, here’s what’s really happening. When you write obj.attr, you think Python is just looking up that attribute in the object’s __dict__, right?”
“Isn’t it?” Timothy asked.
“Not always. Python actually runs through a sequence of checks. And one of those checks is: Is this attribute a descriptor?”
She started typing as she explained:
"""
The Descriptor Protocol: How Python handles attribute access
When you access an attribute (obj.attr), Python doesn't just look it up.
It checks if the attribute is a DESCRIPTOR - an object with special methods:
DESCRIPTOR METHODS:
- __get__(self, obj, objtype=None) → Called on attribute ACCESS
- __set__(self, obj, value) → Called on attribute ASSIGNMENT
- __delete__(self, obj) → Called on attribute DELETION
If an attribute has any of these methods, it's a descriptor!
TYPES OF DESCRIPTORS:
1. Data Descriptor: Has __set__ or __delete__ (higher priority)
2. Non-Data Descriptor: Only has __get__ (lower priority)
LOOKUP ORDER:
1. Data descriptors from type(obj).__mro__
2. obj.__dict__ (instance attributes)
3. Non-data descriptors from type(obj).__mro__
4. Raise AttributeError
"""
“Wait,” Timothy interrupted, scanning the docstring. “So if an object has __get__, __set__, or __delete__ methods, it’s a descriptor?”
“Exactly,” Margaret confirmed. “Those three methods are the descriptor protocol. If Python sees that an attribute has any of these methods, it calls them instead of doing normal attribute lookup.”
“And that’s how @property intercepts attribute access?”
“Precisely. Let me show you that property itself is just a descriptor with these methods.” Margaret wrote a quick demonstration:
def demonstrate_descriptor_magic():
"""Show that property is just a descriptor"""
class Example:
@property
def value(self):
return "Getting value"
obj = Example()
# What IS property, really?
print(f"Type of 'value': {type(Example.value)}") # <class 'property'>
print(f"Has __get__: {hasattr(Example.value, '__get__')}") # True
print(f"Has __set__: {hasattr(Example.value, '__set__')}") # True
print(f"Has __delete__: {hasattr(Example.value, '__delete__')}") # True
print(f"\nIt's a descriptor! {Example.value}")
demonstrate_descriptor_magic()
Output:
Type of 'value': <class 'property'>
Has __get__: True
Has __set__: True
Has __delete__: True
It's a descriptor! <property object at 0x...>
Timothy’s jaw dropped. “Wait - property itself is just an object with __get__, __set__, and __delete__ methods?”
“That’s it. That’s the whole secret,” Margaret said, clearly enjoying his reaction.
“But... that seems almost too simple. I always thought @property was doing something magical with bytecode or metaclasses or something.”
“Nope. It’s just a well-designed descriptor class. Which means,” Margaret said, opening another editor, “we can build our own descriptors from scratch and they’ll work exactly the same way.”
Building a Simple Descriptor from Scratch
“Let me show you the simplest possible descriptor,” Margaret said, her fingers already typing. “We’ll make one that just logs every time someone accesses or modifies an attribute.”
Timothy pulled his chair closer. “Okay, I want to see this.”
class SimpleDescriptor:
"""A basic descriptor that logs attribute access"""
def __init__(self, name):
self.name = name
def __get__(self, obj, objtype=None):
"""Called when attribute is accessed"""
if obj is None:
# Accessed from the class, not an instance
return self
print(f" [GET] Accessing {self.name}")
# Get value from instance's __dict__
return obj.__dict__.get(f'_{self.name}', None)
def __set__(self, obj, value):
"""Called when attribute is assigned"""
print(f" [SET] Setting {self.name} to {value}")
# Store in instance's __dict__ with underscore prefix
obj.__dict__[f'_{self.name}'] = value
def __delete__(self, obj):
"""Called when attribute is deleted"""
print(f" [DELETE] Deleting {self.name}")
del obj.__dict__[f'_{self.name}']
class Person:
# Create descriptor instances as class attributes
name = SimpleDescriptor('name')
age = SimpleDescriptor('age')
def __init__(self, name, age):
self.name = name # Triggers __set__
self.age = age # Triggers __set__
def demonstrate_descriptor():
"""Show descriptor in action"""
print("Creating person:")
person = Person("Alice", 30)
print("\nAccessing attributes:")
print(f"Name: {person.name}") # Triggers __get__
print(f"Age: {person.age}") # Triggers __get__
print("\nModifying attributes:")
person.name = "Bob" # Triggers __set__
print("\nDeleting attribute:")
del person.age # Triggers __delete__
demonstrate_descriptor()
Output:
Creating person:
[SET] Setting name to Alice
[SET] Setting age to 30
Accessing attributes:
[GET] Accessing name
Name: Alice
[GET] Accessing age
Age: 30
Modifying attributes:
[SET] Setting name to Bob
Deleting attribute:
[DELETE] Deleting age
Timothy watched the output appear, then looked up at Margaret in amazement. “That’s incredible. Every time we accessed person.name, Python called our __get__ method. Every time we assigned to person.age, it called __set__. We completely intercepted attribute access!”
“Exactly,” Margaret said. “And notice where the descriptor is defined - as a class attribute on Person, not an instance attribute. That’s important.”
“Why does that matter?” Timothy asked.
“Because Python only looks for descriptors on the class and its parent classes, not on the instance itself. If you try to make a descriptor an instance attribute, Python won’t call the descriptor methods.”
Timothy frowned, thinking. “So the descriptor object lives on the class, but when someone accesses person.name, the descriptor’s __get__ receives that specific person instance as a parameter?”
“Brilliant observation! Yes - that’s why __get__ receives obj and objtype. The obj is the specific instance, and objtype is the class. This lets one descriptor handle attribute access for all instances.”
“Okay, so...” Timothy was working through it. “When I write person.name, Python sees that Person.name is a descriptor, so it calls Person.name.__get__(person, Person). Is that right?”
Margaret beamed. “Perfect. You’ve got it. Now let me show you what this means for @property.”
How @property Actually Works
“So if @property is just a descriptor,” Timothy said slowly, “then there must be a __get__ method that calls our getter function, and a __set__ method that calls our setter function?”
“Exactly!” Margaret pulled up a conceptual implementation:
"""
Here's approximately how @property is implemented:
class property:
def __init__(self, fget=None, fset=None, fdel=None, doc=None):
self.fget = fget # Getter function
self.fset = fset # Setter function
self.fdel = fdel # Deleter function
self.__doc__ = 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) # Call the getter function!
def __set__(self, obj, value):
if self.fset is None:
raise AttributeError("can't set attribute")
self.fset(obj, value) # Call the setter function!
def __delete__(self, obj):
if self.fdel is None:
raise AttributeError("can't delete attribute")
self.fdel(obj) # Call the deleter function!
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__)
"""
Timothy studied the code carefully. “So when we write @property def fahrenheit(self):, we’re creating a property descriptor with our function as the fget parameter?”
“Yes! And when you do @fahrenheit.setter, you’re calling the setter() method, which creates a new property with the same getter but now with your setter function too.”
“That’s... actually really clever,” Timothy said. “It’s like builder pattern. Each decorator call returns a new, slightly modified property object.”
“Exactly. Now watch how this works in practice.” Margaret typed out the Temperature example again:
class Temperature:
def __init__(self, celsius):
self._celsius = celsius
# This creates a property descriptor
@property
def fahrenheit(self):
"""Getter: called on access"""
return self._celsius * 9/5 + 32
# This calls property.setter() to create a new property with a setter
@fahrenheit.setter
def fahrenheit(self, value):
"""Setter: called on assignment"""
self._celsius = (value - 32) * 5/9
def demonstrate_property_internals():
"""Show what @property really does"""
temp = Temperature(100)
# Access triggers __get__, which calls the getter function
print(f"100°C = {temp.fahrenheit}°F") # 212.0
# Assignment triggers __set__, which calls the setter function
temp.fahrenheit = 32
print(f"32°F = {temp._celsius}°C") # 0.0
demonstrate_property_internals()
Timothy ran the code and watched it work. “So when I access temp.fahrenheit, Python calls the property’s __get__ method, which then calls my getter function. And when I assign to temp.fahrenheit, Python calls the property’s __set__ method, which calls my setter function. It’s just delegating to my functions!”
“Perfect understanding,” Margaret confirmed. “This is why properties feel so natural - they’re just wrappers around your getter and setter functions, integrated into Python’s attribute access system.”
“But wait,” Timothy said, a new thought occurring to him. “What if I create an instance attribute with the same name as a property? Which one wins?”
Margaret smiled. “Ah, now you’re asking the important questions. That’s where we need to talk about data descriptors versus non-data descriptors.”
Data Descriptors vs Non-Data Descriptors
“There are actually two types of descriptors,” Margaret began, “and they have different priorities in Python’s attribute lookup.”
Timothy grabbed his notebook. “This sounds important.”
“It is. Let me show you.” Margaret started typing:
class DataDescriptor:
"""Has __set__ → Data descriptor (higher priority)"""
def __get__(self, obj, objtype=None):
print(" DataDescriptor __get__")
return "data descriptor value"
def __set__(self, obj, value):
print(" DataDescriptor __set__")
class NonDataDescriptor:
"""Only __get__ → Non-data descriptor (lower priority)"""
def __get__(self, obj, objtype=None):
print(" NonDataDescriptor __get__")
return "non-data descriptor value"
class Example:
data_desc = DataDescriptor()
non_data_desc = NonDataDescriptor()
def demonstrate_descriptor_priority():
"""Show the difference in priority"""
obj = Example()
print("Data descriptor (high priority):")
print(f" Access: {obj.data_desc}")
# Try to override with instance attribute
obj.__dict__['data_desc'] = "instance value"
print(f" After setting instance attr: {obj.data_desc}")
print(" → Data descriptor WINS (has __set__)\n")
print("Non-data descriptor (low priority):")
print(f" Access: {obj.non_data_desc}")
# Override with instance attribute
obj.__dict__['non_data_desc'] = "instance value"
print(f" After setting instance attr: {obj.non_data_desc}")
print(" → Instance attribute WINS (no __set__)")
demonstrate_descriptor_priority()
Output:
Data descriptor (high priority):
DataDescriptor __get__
Access: data descriptor value
DataDescriptor __get__
After setting instance attr: data descriptor value
→ Data descriptor WINS (has __set__)
Non-data descriptor (low priority):
NonDataDescriptor __get__
Access: non-data descriptor value
After setting instance attr: instance value
→ Instance attribute WINS (no __set__)
Timothy stared at the output. “Wait, this is huge. So if a descriptor has __set__, it takes priority over instance attributes? Even if I explicitly set an instance attribute with the same name?”
“Exactly,” Margaret confirmed. “Data descriptors - those with __set__ or __delete__ - override the instance __dict__. But non-data descriptors - those with only __get__ - can be shadowed by instance attributes.”
“So that’s why I can’t override a @property by assigning to self.property_name in __init__?”
“Precisely! The property has a __set__ method, making it a data descriptor. It always wins.” Margaret leaned back. “This is actually a feature, not a bug. It means you can enforce rules about how attributes are accessed and modified.”
Timothy was scribbling notes. “And this is what Django uses for model fields, isn’t it? To make sure you can’t accidentally bypass their validation by setting instance.field = value directly?”
“You’re connecting the dots beautifully,” Margaret said with approval. “Now let me show you some real-world patterns where this becomes incredibly useful.”
Real-World Use Case 1: Type Validation
“Okay,” Timothy said, “show me something practical. How would I use a descriptor to solve a real problem?”
Margaret’s eyes lit up. “Type validation is perfect. Watch this:”
class TypedDescriptor:
"""Descriptor that enforces type checking"""
def __init__(self, name, expected_type):
self.name = name
self.expected_type = expected_type
def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj.__dict__.get(self.name)
def __set__(self, obj, value):
if not isinstance(value, self.expected_type):
raise TypeError(
f"{self.name} must be {self.expected_type.__name__}, "
f"got {type(value).__name__}"
)
obj.__dict__[self.name] = value
class Person:
name = TypedDescriptor('name', str)
age = TypedDescriptor('age', int)
height = TypedDescriptor('height', (int, float))
def __init__(self, name, age, height):
self.name = name
self.age = age
self.height = height
def demonstrate_type_validation():
"""Show type validation with descriptors"""
# Valid creation
person = Person("Alice", 30, 5.6)
print(f"Created: {person.name}, {person.age}, {person.height}")
# Try invalid types
try:
person.age = "thirty" # Should fail
except TypeError as e:
print(f"✗ Invalid age: {e}")
try:
person.name = 123 # Should fail
except TypeError as e:
print(f"✗ Invalid name: {e}")
# Valid update
person.age = 31
print(f"✓ Updated age: {person.age}")
demonstrate_type_validation()
Output:
Created: Alice, 30, 5.6
✗ Invalid age: age must be int, got str
✗ Invalid name: name must be str, got int
✓ Updated age: 31
Timothy ran the code and grinned when the type errors appeared. “This is brilliant! Instead of checking types in __init__ or having setters everywhere, the descriptor handles it automatically. Every attribute with type checking is just one line: name = TypedDescriptor('name', str).”
“And the validation happens consistently, whether you set the value in __init__ or later,” Margaret added. “No way to bypass it.”
“I can already think of a dozen places I’d use this,” Timothy said, still looking at the code. “Configuration objects, data classes, API request models...”
“Good. But descriptors aren’t just for validation. Let me show you another pattern - lazy loading.”
Real-World Use Case 2: Lazy Loading
Margaret opened a new code window. “Sometimes you have expensive operations - database queries, complex calculations, file I/O - that you don’t want to run until someone actually needs the result. But once computed, you want to cache it.”
“Like @lru_cache but for attributes?” Timothy asked.
“Exactly. Watch:”
class LazyProperty:
"""Descriptor that computes value only once, then caches it"""
def __init__(self, function):
self.function = function
self.name = function.__name__
def __get__(self, obj, objtype=None):
if obj is None:
return self
# Check if value is already cached
if self.name not in obj.__dict__:
print(f" Computing {self.name}...")
# Compute and cache the value
obj.__dict__[self.name] = self.function(obj)
else:
print(f" Using cached {self.name}")
return obj.__dict__[self.name]
class DataProcessor:
def __init__(self, data):
self.data = data
@LazyProperty
def expensive_computation(self):
"""Simulate expensive operation"""
import time
time.sleep(0.1) # Simulate delay
return sum(x ** 2 for x in self.data)
@LazyProperty
def another_expensive_operation(self):
"""Another expensive operation"""
import time
time.sleep(0.1)
return max(self.data) * min(self.data)
def demonstrate_lazy_loading():
"""Show lazy loading pattern"""
processor = DataProcessor([1, 2, 3, 4, 5])
print("First access:")
result1 = processor.expensive_computation # Computes
print("\nSecond access:")
result2 = processor.expensive_computation # Uses cache
print(f"\nResults are same: {result1 == result2}")
print("\nAccessing different property:")
result3 = processor.another_expensive_operation # Computes
demonstrate_lazy_loading()
Output:
First access:
Computing expensive_computation...
Second access:
Using cached expensive_computation
Results are same: True
Accessing different property:
Computing another_expensive_operation...
“Whoa,” Timothy said, watching the output. “First access takes 0.1 seconds because it actually computes. Second access is instant because it’s cached. But we didn’t have to write any caching logic in the class itself - the descriptor handles it all.”
“Exactly. And each instance gets its own cache - the descriptor stores results in obj.__dict__, so different instances don’t share computed values.”
Timothy was already thinking ahead. “This would be perfect for things like @property methods that do expensive calculations. Instead of running the calculation every time someone accesses the property, run it once and cache the result.”
“You’ve got it. This pattern is so useful that Python 3.8 added functools.cached_property which does exactly this,” Margaret said. “But now you understand how it works under the hood.”
“What other patterns are there?” Timothy asked eagerly.
Margaret smiled. “Let me show you one more - auditing and logging.”
Real-World Use Case 3: Attribute Logging/Auditing
“Imagine you’re building a banking application,” Margaret began. “You need to log every single change to account balances for compliance and auditing.”
Timothy nodded. “We could log in every setter method, but that’s error-prone. Someone might forget.”
“Right. But with a descriptor...” Margaret started typing:
import datetime
class AuditedDescriptor:
"""Descriptor that logs all access and modifications"""
def __init__(self, name):
self.name = name
self.log = []
def __get__(self, obj, objtype=None):
if obj is None:
return self
value = obj.__dict__.get(f'_{self.name}')
self.log.append({
'action': 'GET',
'time': datetime.datetime.now(),
'value': value
})
return value
def __set__(self, obj, value):
old_value = obj.__dict__.get(f'_{self.name}')
obj.__dict__[f'_{self.name}'] = value
self.log.append({
'action': 'SET',
'time': datetime.datetime.now(),
'old_value': old_value,
'new_value': value
})
def get_audit_log(self):
"""Return formatted audit log"""
return self.log
class BankAccount:
balance = AuditedDescriptor('balance')
def __init__(self, initial_balance):
self.balance = initial_balance
def deposit(self, amount):
self.balance += amount
def withdraw(self, amount):
self.balance -= amount
def demonstrate_auditing():
"""Show auditing with descriptors"""
account = BankAccount(1000)
# Some transactions
account.deposit(500)
account.withdraw(200)
_ = account.balance # Just checking balance
account.deposit(100)
# Review audit log
print("Audit Log:")
for entry in BankAccount.balance.get_audit_log():
action = entry['action']
time = entry['time'].strftime('%H:%M:%S')
if action == 'SET':
print(f" {time} - SET: {entry['old_value']} → {entry['new_value']}")
else:
print(f" {time} - GET: {entry['value']}")
demonstrate_auditing()
Timothy watched the audit log print out, showing every access and modification with timestamps. “That’s exactly what we need for compliance! Every transaction is automatically logged - deposits, withdrawals, even just checking the balance. And we didn’t have to remember to add logging code anywhere.”
“Right. The descriptor does it transparently,” Margaret confirmed. “And you can extend this - log to a database, send alerts on suspicious patterns, whatever you need.”
“This is making me rethink a lot of our code,” Timothy admitted. “We have so many places where we’re manually logging attribute changes. This would centralize all that logic.”
“It’s powerful, isn’t it?” Margaret said. “And this is exactly why frameworks like Django use descriptors so heavily. Speaking of which - let me show you how Django’s model fields work.”
How Django Uses Descriptors
Timothy perked up. “I’ve always wondered how Django models work. You define fields on the class, but they behave like instance attributes with validation and database access. That’s descriptors, isn’t it?”
“Absolutely,” Margaret said. “Let me show you a simplified version:”
"""
Django's Model fields use descriptors!
class IntegerField:
'''Simplified Django IntegerField'''
def __init__(self, verbose_name=None):
self.name = None
self.verbose_name = verbose_name
def __set_name__(self, owner, name):
# Python 3.6+ automatically calls this
self.name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj.__dict__.get(self.name)
def __set__(self, obj, value):
if value is not None and not isinstance(value, int):
raise TypeError(f"{self.name} must be an integer")
obj.__dict__[self.name] = value
class CharField:
'''Simplified Django CharField'''
def __init__(self, max_length, verbose_name=None):
self.max_length = max_length
self.name = None
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj.__dict__.get(self.name)
def __set__(self, obj, value):
if value is not None and len(value) > self.max_length:
raise ValueError(
f"{self.name} must be at most {self.max_length} characters"
)
obj.__dict__[self.name] = value
class User:
'''Simplified Django model'''
username = CharField(max_length=50)
age = IntegerField()
def __init__(self, username, age):
self.username = username
self.age = age
"""
# Simpler demonstration
class ValidatedField:
"""Simplified field descriptor like Django uses"""
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj.__dict__.get(f'_{self.name}')
def __set__(self, obj, value):
# Validation would happen here
obj.__dict__[f'_{self.name}'] = value
class Model:
"""Simplified Django-like model"""
name = ValidatedField()
email = ValidatedField()
def __init__(self, name, email):
self.name = name
self.email = email
def demonstrate_orm_pattern():
"""Show Django-like descriptor usage"""
user = Model("Alice", "alice@example.com")
print(f"User: {user.name}, {user.email}")
# The fields are descriptors
print(f"\nField type: {type(Model.name)}")
print(f"Is descriptor: {hasattr(Model.name, '__get__')}")
demonstrate_orm_pattern()
“So when I write user = User(name='Alice', email='...') in Django,” Timothy said slowly, “those field descriptors are intercepting the assignment and doing validation, type checking, and preparing for database operations?”
“Exactly. Each field is a descriptor that knows how to validate data, convert it to the right Python type, and later save it to the database. The descriptor lives on the class, but manages data for each instance.”
Timothy shook his head in wonder. “I’ve been using Django for two years and had no idea this is how it worked. I thought it was some kind of metaclass magic.”
“Descriptors are used with metaclasses in Django,” Margaret admitted, “but the core mechanism for fields is the descriptor protocol. Now let me blow your mind even more.”
“More?” Timothy asked.
“Methods are descriptors too.”
Method Descriptors: @classmethod and @staticmethod
Timothy blinked. “Wait, what? Regular methods are descriptors?”
“Everything,” Margaret said with a grin. “Regular methods, classmethods, staticmethods - they’re all implemented with descriptors.”
She pulled up an example:
"""
Yes! Here's how they work:
class classmethod:
def __init__(self, function):
self.function = function
def __get__(self, obj, objtype=None):
if objtype is None:
objtype = type(obj)
# Return a bound method with the CLASS as first argument
return lambda *args, **kwargs: self.function(objtype, *args, **kwargs)
class staticmethod:
def __init__(self, function):
self.function = function
def __get__(self, obj, objtype=None):
# Return the function unchanged (no binding)
return self.function
"""
class Example:
regular_value = "I'm a regular class attribute"
def regular_method(self):
"""Regular method: gets instance"""
return f"Instance method on {self}"
@classmethod
def class_method(cls):
"""Class method: gets class via descriptor"""
return f"Class method on {cls}"
@staticmethod
def static_method():
"""Static method: gets nothing via descriptor"""
return "Static method"
def demonstrate_method_descriptors():
"""Show how method decorators are descriptors"""
obj = Example()
print("Checking descriptor protocol:")
print(f" regular_method has __get__: {hasattr(Example.regular_method, '__get__')}")
print(f" classmethod has __get__: {hasattr(Example.class_method, '__get__')}")
print(f" staticmethod has __get__: {hasattr(Example.static_method, '__get__')}")
print("\nCalling methods:")
print(f" {obj.regular_method()}")
print(f" {obj.class_method()}")
print(f" {obj.static_method()}")
demonstrate_method_descriptors()
Output:
Checking descriptor protocol:
regular_method has __get__: True
classmethod has __get__: True
staticmethod has __get__: True
Calling methods:
Instance method on <__main__.Example object at 0x...>
Class method on <class '__main__.Example'>
Static method
Timothy stared at the output. “They all have __get__. So when I call obj.method(), Python is using the descriptor protocol to bind the method to the instance?”
“Exactly! Regular methods are actually functions with a __get__ method. When you access obj.method, the descriptor __get__ returns a bound method - a wrapper that automatically passes obj as the first argument.”
“So self isn’t magic,” Timothy said, the pieces clicking together. “It’s just the descriptor protocol binding the function to the instance.”
“Precisely. And @classmethod and @staticmethod are just different descriptors that bind differently - one to the class, one not at all.”
Timothy leaned back, processing. “It really is descriptors all the way down.”
“Almost,” Margaret said. “There’s one more modern convenience I should show you - a feature that makes writing descriptors even easier.”
The set_name Hook (Python 3.6+)
“Earlier,” Margaret continued, “when we wrote our TypedDescriptor, we had to pass the attribute name as a string, remember? name = TypedDescriptor('name', str). That felt redundant - we’re assigning it to name, so Python already knows the name.”
Timothy nodded. “Yeah, I noticed that. It’s like repeating yourself.”
“Python 3.6 added a solution.” Margaret typed:
class NamedDescriptor:
"""Descriptor that automatically knows its attribute name"""
# __set_name__ is called when the class is created
def __set_name__(self, owner, name):
print(f" Descriptor created for {owner.__name__}.{name}")
self.name = name
self.private_name = f'_{name}'
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.private_name, None)
def __set__(self, obj, value):
setattr(obj, self.private_name, value)
class Person:
# No need to pass name manually!
name = NamedDescriptor()
age = NamedDescriptor()
def __init__(self, name, age):
self.name = name
self.age = age
def demonstrate_set_name():
"""Show __set_name__ in action"""
print("Class definition triggers __set_name__:")
# (Already printed during class creation above)
print("\nUsing the descriptors:")
person = Person("Alice", 30)
print(f" {person.name}, age {person.age}")
demonstrate_set_name()
Output:
Class definition triggers __set_name__:
Descriptor created for Person.name
Descriptor created for Person.age
Using the descriptors:
Alice, age 30
“That’s so much cleaner!” Timothy said. “Python automatically calls __set_name__ when the class is created and tells the descriptor its own name. So I can just write name = NamedDescriptor() without passing the name string.”
“Exactly. It eliminates the redundancy and reduces errors. If you rename an attribute, you don’t have to remember to change the string too.”
“This feels like a language that’s learning from its users,” Timothy observed. “Adding conveniences for common patterns.”
“That’s Python’s philosophy,” Margaret agreed. “Now, before you run off and descriptor-ify all your code, let me show you some common mistakes to avoid.”
Common Pitfalls and Gotchas
“Hit me with the gotchas,” Timothy said, notebook ready. “I’d rather learn them from you than from production bugs.”
Margaret laughed. “Smart. Here are the big ones:”
def pitfall_1_sharing_mutable_state():
"""Pitfall: Descriptor instances are shared across all instances"""
class BrokenDescriptor:
def __init__(self):
self.value = None # ❌ WRONG - shared across all instances!
def __get__(self, obj, objtype=None):
return self.value
def __set__(self, obj, value):
self.value = value # All instances share this!
class Example:
attr = BrokenDescriptor()
obj1 = Example()
obj2 = Example()
obj1.attr = "obj1's value"
print(f"obj1.attr: {obj1.attr}")
print(f"obj2.attr: {obj2.attr}") # ✗ Same value!
print(" Descriptor state is shared!\n")
def pitfall_2_forgetting_obj_none():
"""Pitfall: Not handling obj=None in __get__"""
class BrokenDescriptor:
def __get__(self, obj, objtype=None):
# ❌ WRONG - will fail when accessed from class
return obj.value # AttributeError if obj is None!
class FixedDescriptor:
def __get__(self, obj, objtype=None):
if obj is None: # ✓ CORRECT
return self
return obj.__dict__.get('value')
class Example:
broken = BrokenDescriptor()
fixed = FixedDescriptor()
try:
print(Example.broken) # Accessing from class, not instance
except AttributeError as e:
print(f"✗ BrokenDescriptor: {e}\n")
print(f"✓ FixedDescriptor: {Example.fixed}")
def pitfall_3_property_without_setter():
"""Pitfall: Trying to set a read-only property"""
class Example:
@property
def readonly(self):
return "can't change me"
obj = Example()
print(f"Reading: {obj.readonly}")
try:
obj.readonly = "new value"
except AttributeError as e:
print(f"✗ Cannot set: {e}")
pitfall_1_sharing_mutable_state()
pitfall_2_forgetting_obj_none()
pitfall_3_property_without_setter()
Timothy studied the three pitfalls carefully. “Okay, so the key lessons are: store data on the instance not the descriptor, always handle obj=None in __get__, and remember that properties without setters are read-only by design.”
“Exactly. These are the mistakes almost everyone makes when first learning descriptors,” Margaret said. “Knowing them ahead of time saves hours of debugging.”
“How do I test descriptor behavior properly?” Timothy asked. “It seems like there are a lot of edge cases.”
“Great question. Let me show you some testing patterns.”
Testing Descriptor Behavior
Margaret opened a test file:
import pytest
def test_descriptor_get():
"""Test descriptor __get__ method"""
class TestDescriptor:
def __get__(self, obj, objtype=None):
if obj is None:
return self
return "descriptor value"
class Example:
attr = TestDescriptor()
obj = Example()
assert obj.attr == "descriptor value"
assert Example.attr is Example.attr # Returns self from class
def test_descriptor_set():
"""Test descriptor __set__ method"""
class TestDescriptor:
def __init__(self, name):
self.name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj.__dict__.get(self.name)
def __set__(self, obj, value):
obj.__dict__[self.name] = value.upper()
class Example:
attr = TestDescriptor('attr')
obj = Example()
obj.attr = "hello"
assert obj.attr == "HELLO"
def test_type_validation_descriptor():
"""Test descriptor type validation"""
class TypedDescriptor:
def __init__(self, name, expected_type):
self.name = name
self.expected_type = expected_type
def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj.__dict__.get(self.name)
def __set__(self, obj, value):
if not isinstance(value, self.expected_type):
raise TypeError(f"Must be {self.expected_type.__name__}")
obj.__dict__[self.name] = value
class Example:
age = TypedDescriptor('age', int)
obj = Example()
obj.age = 30
assert obj.age == 30
with pytest.raises(TypeError):
obj.age = "thirty"
def test_lazy_property():
"""Test lazy loading descriptor"""
call_count = 0
class LazyProperty:
def __init__(self, func):
self.func = func
self.name = func.__name__
def __get__(self, obj, objtype=None):
if obj is None:
return self
if self.name not in obj.__dict__:
obj.__dict__[self.name] = self.func(obj)
return obj.__dict__[self.name]
class Example:
@LazyProperty
def expensive(self):
nonlocal call_count
call_count += 1
return "expensive result"
obj = Example()
# First access computes
result1 = obj.expensive
assert result1 == "expensive result"
assert call_count == 1
# Second access uses cache
result2 = obj.expensive
assert result2 == "expensive result"
assert call_count == 1 # Not called again!
# Run with: pytest test_descriptors.py -v
Timothy looked over the test patterns. “These cover all the key behaviors - getting, setting, type validation, lazy loading. I like that they’re testing the descriptor’s effect on the class, not the descriptor implementation itself.”
“That’s the right approach,” Margaret confirmed. “Test what matters - does the descriptor do what it’s supposed to do when used in a class?”
Timothy closed his laptop and leaned back, thinking. “Okay, I understand the mechanics now - the three methods, the lookup order, the patterns. But help me see the bigger picture. When I’m looking at code, how should I think about descriptors?”
Margaret smiled. “Let me give you a metaphor.”
The Library Metaphor
“Think of descriptors as librarians who intercept book requests,” Margaret began.
“When you ask for a book (access an attribute), you’re not getting the book directly from the shelf. You’re asking a librarian (the descriptor), who decides how to handle your request.
“The librarian might:
- Keep a log of who requested which books (
@propertywith logging) - Only let you borrow certain books if you have the right credentials (validation)
- Fetch the book from storage only the first time, then keep it at the front desk for repeat requests (lazy loading)
- Replace old editions with new ones automatically (computed properties)
“Regular attributes are like self-service shelves - you grab them directly. Descriptors are like managed services where a librarian intercepts and handles the request with custom logic.
“This is how @property turns method calls into attribute access, how Django creates database-backed model fields, and how Python itself implements methods, classmethods, and staticmethods. They’re all descriptors - managed attribute access with custom behavior.”
Timothy nodded, the metaphor settling in. “So regular attributes are self-service - you just grab them from __dict__. But descriptors are managed services where a librarian intercepts the request and can add logic - validation, caching, logging, computed values.”
“Exactly. And this is why @property feels so natural - it’s just a librarian who computes the book title based on other information. Django fields are librarians who check permissions and handle the database. Methods are librarians who bind the book to you personally.”
“It’s a consistent pattern everywhere in Python,” Timothy said, realizing. “Once you see it, you can’t unsee it.”
“That’s the sign of a good abstraction,” Margaret said with a smile. “Now let me give you a reference guide for when to use which patterns.”
Common Use Cases Summary
Timothy pulled out a fresh page in his notebook. “Give me the cheat sheet. When should I reach for descriptors?”
Margaret enumerated:
"""
DESCRIPTOR USE CASES:
1. COMPUTED PROPERTIES (@property)
- Derive values from other attributes
- Add getters/setters without changing API
- Temperature conversion, full names, etc.
2. TYPE VALIDATION
- Enforce types on attributes
- Prevent invalid data
- Useful for data classes, config objects
3. LAZY LOADING
- Compute expensive values only once
- Cache results automatically
- Database queries, file operations
4. LOGGING/AUDITING
- Track attribute access and modifications
- Compliance, debugging
- Security-sensitive data
5. FRAMEWORK MAGIC
- Django ORM fields
- SQLAlchemy columns
- Marshmallow fields
- Any "declarative" API
6. METHOD BINDING
- How regular methods work
- @classmethod and @staticmethod
- Built into Python itself
"""
“That’s a great summary,” Timothy said, reviewing his notes. “Computed properties, validation, lazy loading, auditing, framework magic, and method binding. Those cover most use cases.”
“One more thing you should know,” Margaret said. “Performance.”
Timothy looked up. “Are descriptors slow?”
“Not significantly,” Margaret reassured him. “But there is a small overhead. Let me show you.”
Performance Considerations
Margaret opened a benchmark:
import time
def benchmark_descriptor_overhead():
"""Compare regular attribute vs descriptor access"""
class RegularClass:
def __init__(self):
self.value = 42
class DescriptorClass:
class SimpleDescriptor:
def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj.__dict__['_value']
def __set__(self, obj, value):
obj.__dict__['_value'] = value
value = SimpleDescriptor()
def __init__(self):
self.value = 42
# Benchmark regular attribute
obj1 = RegularClass()
start = time.perf_counter()
for _ in range(1000000):
_ = obj1.value
regular_time = time.perf_counter() - start
# Benchmark descriptor
obj2 = DescriptorClass()
start = time.perf_counter()
for _ in range(1000000):
_ = obj2.value
descriptor_time = time.perf_counter() - start
print(f"1 million attribute accesses:")
print(f" Regular attribute: {regular_time:.4f} seconds")
print(f" Descriptor: {descriptor_time:.4f} seconds")
print(f" Overhead: {descriptor_time / regular_time:.2f}x")
print("\n💡 Descriptors have small overhead, but add powerful features!")
benchmark_descriptor_overhead()
Timothy watched the benchmark results. “So about 1.5-2x slower than direct attribute access. That’s not bad at all for the features you get - validation, caching, logging, whatever.”
“Exactly,” Margaret confirmed. “The overhead is a method call to __get__ or __set__. For most applications, that’s negligible. And the benefits - cleaner code, enforced validation, centralized logic - far outweigh the small performance cost.”
“When would the overhead matter?” Timothy asked.
“Only in extremely tight loops with millions of attribute accesses,” Margaret said. “And even then, you’d profile first before optimizing. Premature optimization is the root of all evil, remember?”
Timothy grinned. “Knuth’s wisdom.”
“Always,” Margaret said. “Now let me give you the final summary - everything you need to remember about descriptors.”
Key Takeaways
Margaret pulled up a comprehensive summary:
"""
DESCRIPTOR PROTOCOL KEY TAKEAWAYS:
1. Descriptors are objects with __get__, __set__, or __delete__
- Control attribute access with custom logic
- Foundation for @property, @classmethod, @staticmethod
2. Two types of descriptors:
- Data descriptors (have __set__ or __delete__): high priority
- Non-data descriptors (only __get__): low priority
3. Lookup order matters:
- Data descriptors > instance __dict__ > non-data descriptors
- This is why properties can't be overridden by instance attributes
4. @property is just a built-in descriptor
- Calls your getter/setter functions
- Makes methods look like attributes
- Clean API without changing internals
5. Real-world applications:
- Type validation (enforce data constraints)
- Lazy loading (compute once, cache)
- Auditing (log all access)
- ORM fields (Django, SQLAlchemy)
- Method binding (how 'self' works)
6. Modern convenience (__set_name__):
- Python 3.6+ automatically passes attribute name
- No need to manually specify names
- Makes descriptors easier to write
7. Common pitfalls:
- Descriptor state is shared across instances
- Must handle obj=None in __get__
- Store instance data in obj.__dict__, not descriptor
8. Performance:
- Small overhead (~1.5-2x slower than direct access)
- Worth it for validation, caching, logging
- Not noticeable in most applications
9. When to use descriptors:
- Need custom get/set logic
- Validation, transformation, caching
- Building frameworks or declarative APIs
- When @property isn't flexible enough
10. When NOT to use descriptors:
- Simple attributes (use regular attributes)
- One-off computed properties (use @property)
- Premature optimization
"""
Timothy read through the entire summary carefully, occasionally nodding or making notes. When he finished, he looked up at Margaret with a mixture of amazement and satisfaction.
“So descriptors are the mechanism behind so many Python features I use every day,” he said slowly, working through his thoughts. “@property is just a descriptor. Methods are descriptors that bind to instances. @classmethod and @staticmethod are descriptors with different binding rules. Django’s model fields are descriptors. Even the way self works in methods - it’s all descriptors.”
He tapped his pen on his notebook. “It’s like finding out the hidden plumbing behind every beautiful façade. Once you see it, Python makes so much more sense.”
“That’s the beauty of good language design,” Margaret said. “One simple protocol - three methods - and suddenly you can intercept attribute access in any way you need. Validation, caching, logging, computed values, framework magic - all built on the same foundation.”
Timothy closed his laptop and leaned back in his chair. “I came in here confused about @property, and I’m leaving understanding how half of Python actually works under the hood.”
“Not half,” Margaret corrected with a smile. “But definitely some of the most powerful parts.”
“When I see @property in code now,” Timothy continued, “I won’t just think ‘oh, it’s a computed attribute.’ I’ll think: ‘that’s a descriptor with a __get__ method that calls the getter function.’ When I see Django model fields, I’ll recognize the descriptor pattern. When I write a method, I’ll know that Python is using __get__ to bind it to the instance.”
“And when you need custom attribute access logic,” Margaret added, “you’ll know you can write your own descriptor. Type validation, lazy loading, auditing - whatever the problem, descriptors can solve it elegantly.”
Timothy stood up, gathering his notes. “Thanks, Margaret. This was one of those conversations that changes how I see the whole language.”
“Those are the best kind,” Margaret said. “Now go forth and descriptor-ify your code - but wisely. Remember the pitfalls, remember the trade-offs, and remember that sometimes a simple attribute is perfectly fine.”
“Don’t let perfection be the enemy of good,” Timothy quoted.
“Exactly. Use descriptors when they add real value - validation that needs to be enforced, computations that need to be cached, access that needs to be logged. Don’t use them just because you can.”
With that knowledge, Timothy understood one of Python’s most powerful protocols, could recognize it throughout the language and frameworks, and knew when to leverage it in his own code. The descriptor protocol - three simple methods that unlock infinite possibilities for controlling attribute access.
Aaron Rose is a software engineer and technology writer at tech-reader.blog and the author of Think Like a Genius.