Timothy was examining a class definition when something caught his attention. "Margaret, I don't understand how @property actually works. I write @property above a method, and suddenly it acts like an attribute. How does Python know to call my method when I access obj.name instead of obj.name()? What's the magic?"
Margaret grinned. "That's not magic - it's descriptors. They're one of Python's most powerful but least understood features. @property is just a convenient wrapper around the descriptor protocol. Once you understand descriptors, you'll see them everywhere - properties, methods, classmethods, staticmethods. They're the foundation of how attribute access works in Python."
"Descriptor protocol?" Timothy looked puzzled. "I've never heard of that."
"Most people haven't," Margaret said. "But descriptors control what happens when you access an attribute. Let me show you the mystery first, then we'll uncover how it really works."
The Puzzle: The Invisible Method Calls
Timothy showed Margaret some confusing behavior:
class Person:
def __init__(self, name):
self._name = name
@property
def name(self):
"""This is a method..."""
print("Getting name")
return self._name
@name.setter
def name(self, value):
"""But it acts like an attribute!"""
print(f"Setting name to {value}")
self._name = value
# Using it
person = Person("Alice")
print(person.name) # Calls the method, but no parentheses!
person.name = "Bob" # Calls the setter, but looks like assignment!
"See?" Timothy pointed. "I access person.name like a normal attribute, but it's actually calling a method. And I can assign to it with =, but that calls a different method. How does Python intercept attribute access like this?"
"Perfect observation," Margaret said. "Python has a special protocol for controlling attribute access. When you write obj.attr, Python doesn't just return the value. It checks if attr is a descriptor - an object with special methods that control access. Properties are descriptors. Methods are descriptors. Let me show you how they work."
What Are Descriptors?
Margaret pulled up a comprehensive explanation:
"""
DESCRIPTORS: Objects that control attribute access
A descriptor is any object that implements one or more of:
- __get__(self, obj, type=None): Called on attribute access (obj.attr)
- __set__(self, obj, value): Called on attribute assignment (obj.attr = value)
- __delete__(self, obj): Called on attribute deletion (del obj.attr)
THE DESCRIPTOR PROTOCOL:
When you access obj.attr:
1. Python checks if 'attr' is a data descriptor (has __get__ AND __set__)
2. If not, checks instance __dict__
3. If not there, checks if 'attr' is a non-data descriptor (only __get__)
4. If not, checks class __dict__
5. If not found anywhere, raises AttributeError
DESCRIPTORS ARE:
- Data descriptors: Have __get__ and __set__ (override instance __dict__)
- Non-data descriptors: Only have __get__ (instance __dict__ wins)
- The mechanism behind @property, methods, classmethods, staticmethods
"""
def demonstrate_descriptor_basics():
"""Show basic descriptor behavior"""
class SimpleDescriptor:
"""A basic descriptor"""
def __get__(self, obj, objtype=None):
print(f" __get__ called")
print(f" self: {self}")
print(f" obj: {obj}")
print(f" objtype: {objtype}")
return "descriptor value"
def __set__(self, obj, value):
print(f" __set__ called")
print(f" self: {self}")
print(f" obj: {obj}")
print(f" value: {value}")
class MyClass:
descriptor = SimpleDescriptor()
print("Accessing descriptor:")
obj = MyClass()
result = obj.descriptor
print(f" Returned: {result}\n")
print("Setting descriptor:")
obj.descriptor = "new value"
print("\nAccessing from class:")
result = MyClass.descriptor
print(f" Returned: {result}")
demonstrate_descriptor_basics()
Output:
Accessing descriptor:
__get__ called
self: <__main__.SimpleDescriptor object at 0x...>
obj: <__main__.MyClass object at 0x...>
objtype: <class '__main__.MyClass'>
Returned: descriptor value
Setting descriptor:
__set__ called
self: <__main__.SimpleDescriptor object at 0x...>
obj: <__main__.MyClass object at 0x...>
value: new value
Accessing from class:
__get__ called
self: <__main__.SimpleDescriptor object at 0x...>
obj: None
objtype: <class '__main__.MyClass'>
Returned: descriptor value
Timothy studied the output carefully. "So when I access obj.descriptor, Python doesn't just return the descriptor object. It calls the descriptor's __get__ method with the instance and class. And when I assign, it calls __set__. The descriptor controls what happens!"
"Exactly," Margaret confirmed. "Notice how __get__ receives three parameters: self (the descriptor instance), obj (the object being accessed), and objtype (the class). This lets the descriptor customize behavior based on whether it's accessed from an instance or the class itself."
"But how does this relate to @property?" Timothy asked.
Property: A Built-in Descriptor
"Perfect question," Margaret said. "Property is just a descriptor that Python provides. Let me show you how to build your own property-like descriptor."
def demonstrate_property_equivalent():
"""Show that property is just a descriptor"""
class PropertyDescriptor:
"""Our own version of property"""
def __init__(self, fget=None, fset=None, fdel=None):
self.fget = fget
self.fset = fset
self.fdel = fdel
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 setter(self, fset):
"""Decorator for setter"""
return type(self)(self.fget, fset, self.fdel)
class Temperature:
def __init__(self, celsius):
self._celsius = celsius
@PropertyDescriptor
def celsius(self):
print(" Getting celsius")
return self._celsius
@celsius.setter
def celsius(self, value):
print(f" Setting celsius to {value}")
if value < -273.15:
raise ValueError("Temperature below absolute zero!")
self._celsius = value
print("Using our property descriptor:")
temp = Temperature(25)
print(f" temp.celsius = {temp.celsius}")
temp.celsius = 30
print(f" temp.celsius = {temp.celsius}")
print("\n✓ We built our own @property!")
demonstrate_property_equivalent()
Output:
Using our property descriptor:
Getting celsius
temp.celsius = 25
Setting celsius to 30
Getting celsius
temp.celsius = 30
✓ We built our own @property!
"That's amazing!" Timothy exclaimed. "So @property is just a descriptor that stores the getter, setter, and deleter functions, then calls them when accessed. The decorator syntax is just a convenient way to set it up."
"Exactly. Now you understand what @property really is - syntactic sugar around a descriptor. Let me show you the difference between data and non-data descriptors."
Data vs Non-Data Descriptors
Margaret pulled up a critical distinction:
def demonstrate_descriptor_types():
"""Show data vs non-data descriptors"""
class DataDescriptor:
"""Has __get__ and __set__ - overrides instance dict"""
def __get__(self, obj, objtype=None):
print(" DataDescriptor.__get__")
return "data descriptor"
def __set__(self, obj, value):
print(" DataDescriptor.__set__")
class NonDataDescriptor:
"""Only has __get__ - instance dict wins"""
def __get__(self, obj, objtype=None):
print(" NonDataDescriptor.__get__")
return "non-data descriptor"
class MyClass:
data = DataDescriptor()
nondata = NonDataDescriptor()
obj = MyClass()
print("Data descriptor:")
print(f" Access: {obj.data}")
obj.data = "instance value"
print(f" After assignment: {obj.data}")
print(f" Instance dict: {obj.__dict__}\n")
print("Non-data descriptor:")
print(f" Access: {obj.nondata}")
obj.nondata = "instance value"
print(f" After assignment: {obj.nondata}")
print(f" Instance dict: {obj.__dict__}")
print("\n ✓ Instance attribute shadowed the non-data descriptor!")
demonstrate_descriptor_types()
Output:
Data descriptor:
DataDescriptor.__get__
Access: data descriptor
DataDescriptor.__set__
DataDescriptor.__get__
After assignment: data descriptor
Instance dict: {}
Non-data descriptor:
NonDataDescriptor.__get__
Access: non-data descriptor
After assignment: instance value
Instance dict: {'nondata': 'instance value'}
✓ Instance attribute shadowed the non-data descriptor!
"Whoa!" Timothy said. "With the data descriptor, the assignment went through __set__, and nothing appeared in the instance __dict__. But with the non-data descriptor, the assignment created an instance attribute that shadowed the descriptor."
"Exactly," Margaret confirmed. "This is the key distinction. Data descriptors (with both __get__ and __set__) take precedence over the instance dictionary. Non-data descriptors (only __get__) are overridden by instance attributes. This is why methods are non-data descriptors - you can override them on instances."
"So that's why I can do obj.method = something_else?" Timothy asked.
"Right! Methods are non-data descriptors. Let me show you how methods actually work."
Methods Are Descriptors
Margaret opened an eye-opening example:
def demonstrate_method_descriptor():
"""Show that methods are descriptors"""
class MyClass:
def method(self):
"""Regular method"""
return f"Called method on {self}"
obj = MyClass()
print("Method descriptor behavior:")
print(f" From class: {MyClass.method}")
print(f" From instance: {obj.method}")
print(f" Type from class: {type(MyClass.method)}")
print(f" Type from instance: {type(obj.method)}\n")
print("Calling methods:")
# These are equivalent:
result1 = obj.method()
result2 = MyClass.method(obj)
print(f" obj.method(): {result1}")
print(f" MyClass.method(obj): {result2}")
print("\n ✓ Instance method access binds 'self' automatically!")
# What's really happening
print("\nThe descriptor protocol at work:")
method_descriptor = MyClass.__dict__['method']
print(f" Method object: {method_descriptor}")
# Calling __get__ manually
bound_method = method_descriptor.__get__(obj, MyClass)
print(f" After __get__: {bound_method}")
print(f" Calling it: {bound_method()}")
demonstrate_method_descriptor()
Output:
Method descriptor behavior:
From class: <function MyClass.method at 0x...>
From instance: <bound method MyClass.method of <__main__.MyClass object at 0x...>>
Type from class: <class 'function'>
Type from instance: <class 'method'>
Calling methods:
obj.method(): Called method on {obj}
MyClass.method(obj): Called method on {obj}
✓ Instance method access binds 'self' automatically!
The descriptor protocol at work:
Method object: <function MyClass.method at 0x...>
After __get__: <bound method MyClass.method of <__main__.MyClass object at 0x...>>
Calling it: Called method on {obj}
Timothy's eyes widened. "So when I access obj.method, Python doesn't just return the function. It calls the function's __get__ method, which returns a bound method with self already filled in. That's how methods automatically get self!"
"Exactly! Functions are non-data descriptors. When accessed through an instance, their __get__ method returns a bound method. This is fundamental to how Python's object system works."
"What about @classmethod and @staticmethod?" Timothy asked.
Classmethod and Staticmethod: Descriptor Implementations
Margaret showed the implementations:
def demonstrate_classmethod_staticmethod():
"""Show classmethod and staticmethod as descriptors"""
class ClassMethodDescriptor:
"""Our own @classmethod"""
def __init__(self, func):
self.func = func
def __get__(self, obj, objtype=None):
if objtype is None:
objtype = type(obj)
# Return function with class bound as first argument
def bound_method(*args, **kwargs):
return self.func(objtype, *args, **kwargs)
return bound_method
class StaticMethodDescriptor:
"""Our own @staticmethod"""
def __init__(self, func):
self.func = func
def __get__(self, obj, objtype=None):
# Just return the original function (no binding)
return self.func
class MyClass:
@ClassMethodDescriptor
def class_method(cls):
return f"Class method called on {cls.__name__}"
@StaticMethodDescriptor
def static_method():
return "Static method called (no self or cls)"
obj = MyClass()
print("Classmethod:")
print(f" From instance: {obj.class_method()}")
print(f" From class: {MyClass.class_method()}\n")
print("Staticmethod:")
print(f" From instance: {obj.static_method()}")
print(f" From class: {MyClass.static_method()}")
print("\n✓ We built our own @classmethod and @staticmethod!")
demonstrate_classmethod_staticmethod()
Output:
Classmethod:
From instance: Class method called on MyClass
From class: Class method called on MyClass
Staticmethod:
From instance: Static method called (no self or cls)
From class: Static method called (no self or cls)
✓ We built our own @classmethod and @staticmethod!
"So @classmethod and @staticmethod are just descriptors that customize how methods are bound," Timothy observed. "Classmethod binds the class as the first argument, and staticmethod doesn't bind anything at all."
"Exactly. Descriptors are the universal mechanism for customizing attribute access. Now let me show you some powerful real-world patterns."
Real-World Use Case 1: Type Validation
Margaret pulled up a practical example:
def demonstrate_validation_descriptor():
"""Show type validation using descriptors"""
class TypedProperty:
"""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 = TypedProperty('name', str)
age = TypedProperty('age', int)
def __init__(self, name, age):
self.name = name
self.age = age
print("Valid assignments:")
person = Person("Alice", 30)
print(f" {person.name}, age {person.age}\n")
print("Type checking in action:")
try:
person.name = 123 # Should fail
except TypeError as e:
print(f" ✗ {e}")
try:
person.age = "thirty" # Should fail
except TypeError as e:
print(f" ✗ {e}")
print("\n ✓ Descriptors enforce type safety!")
demonstrate_validation_descriptor()
Output:
Valid assignments:
Alice, age 30
Type checking in action:
✗ name must be str, got int
✗ age must be int, got str
✓ Descriptors enforce type safety!
"That's really useful!" Timothy said. "Instead of validating in __init__ and every setter, the descriptor handles it automatically. Any assignment to that attribute goes through validation."
"Exactly. This is how data classes, Pydantic, and other validation libraries work under the hood. Let me show you another powerful pattern."
Real-World Use Case 2: Lazy Attributes
Margaret showed a performance optimization:
def demonstrate_lazy_property():
"""Show lazy evaluation using descriptors"""
class LazyProperty:
"""Descriptor that computes value once and caches it"""
def __init__(self, func):
self.func = func
self.name = func.__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}...")
obj.__dict__[self.name] = self.func(obj)
else:
print(f" Using cached {self.name}")
return obj.__dict__[self.name]
class DataProcessor:
def __init__(self, data):
self.data = data
@LazyProperty
def processed_data(self):
"""Expensive computation"""
import time
time.sleep(0.1) # Simulate expensive operation
return [x * 2 for x in self.data]
print("Creating processor:")
processor = DataProcessor([1, 2, 3, 4, 5])
print("\nFirst access (computes):")
print(f" Result: {processor.processed_data}")
print("\nSecond access (cached):")
print(f" Result: {processor.processed_data}")
print("\nThird access (still cached):")
print(f" Result: {processor.processed_data}")
demonstrate_lazy_property()
Output:
Creating processor:
First access (computes):
Computing processed_data...
Result: [2, 4, 6, 8, 10]
Second access (cached):
Using cached processed_data
Result: [2, 4, 6, 8, 10]
Third access (still cached):
Using cached processed_data
Result: [2, 4, 6, 8, 10]
"Clever!" Timothy said. "The first access computes the value and stores it in the instance dictionary. After that, the instance attribute shadows the non-data descriptor, so subsequent accesses just return the cached value."
"Exactly. This is a common optimization pattern. Django and other frameworks use it for expensive database queries or computations."
Real-World Use Case 3: Attribute Logging
Margaret showed a debugging pattern:
def demonstrate_logging_descriptor():
"""Show attribute access logging"""
class LoggedAttribute:
"""Descriptor that logs all access"""
def __init__(self, name):
self.name = name
self.internal_name = f'_{name}'
def __get__(self, obj, objtype=None):
if obj is None:
return self
value = getattr(obj, self.internal_name, None)
print(f" [GET] {self.name} = {value}")
return value
def __set__(self, obj, value):
print(f" [SET] {self.name} = {value}")
setattr(obj, self.internal_name, value)
def __delete__(self, obj):
print(f" [DEL] {self.name}")
delattr(obj, self.internal_name)
class BankAccount:
balance = LoggedAttribute('balance')
def __init__(self, initial_balance):
self.balance = initial_balance
print("Creating account and performing operations:")
account = BankAccount(1000)
current = account.balance
account.balance = 1500
current = account.balance
print("\n✓ All attribute access is logged!")
demonstrate_logging_descriptor()
Output:
Creating account and performing operations:
[SET] balance = 1000
[GET] balance = 1000
[SET] balance = 1500
[GET] balance = 1500
✓ All attribute access is logged!
"This is perfect for debugging," Timothy noted. "You can track exactly when and how attributes are being accessed without modifying the code that uses them."
"Right. Descriptors let you add cross-cutting concerns like logging, validation, or caching without cluttering your business logic."
"I noticed in the TypedProperty example we had to pass the name manually," Timothy said. "Is there a better way?"
Modern Python: set_name and delete
"Great observation!" Margaret said. "Python 3.6 added __set_name__ which eliminates that manual name passing. Let me show you both that and the __delete__ method we haven't demonstrated yet."
def demonstrate_modern_descriptor_features():
"""Show __set_name__ and __delete__ in action"""
class AutoNamedDescriptor:
"""Python 3.6+ automatically captures the attribute name"""
def __set_name__(self, owner, name):
"""Called when descriptor is assigned to class attribute"""
print(f" __set_name__ called: {name}")
self.name = name
self.internal_name = f'_{name}'
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.internal_name, None)
def __set__(self, obj, value):
setattr(obj, self.internal_name, value)
def __delete__(self, obj):
"""Called when attribute is deleted"""
print(f" __delete__ called for {self.name}")
delattr(obj, self.internal_name)
class Person:
name = AutoNamedDescriptor() # No need to pass 'name'!
age = AutoNamedDescriptor() # Python calls __set_name__ automatically
print("Creating class with __set_name__:")
# __set_name__ already called during class creation
print("\nUsing the descriptor:")
person = Person()
person.name = "Alice"
person.age = 30
print(f" Name: {person.name}, Age: {person.age}")
print("\nDeleting attributes:")
del person.name
del person.age
print("\n✓ __set_name__ eliminates manual name passing!")
print("✓ __delete__ handles attribute deletion!")
demonstrate_modern_descriptor_features()
Output:
Creating class with __set_name__:
__set_name__ called: name
__set_name__ called: age
Using the descriptor:
Name: Alice, Age: 30
Deleting attributes:
__delete__ called for name
__delete__ called for age
✓ __set_name__ eliminates manual name passing!
✓ __delete__ handles attribute deletion!
"That's much cleaner!" Timothy said. "No more passing the name string to __init__. Python calls __set_name__ automatically when the class is created, so the descriptor knows its own name."
"Exactly. And __delete__ completes the protocol - you can now control what happens when someone uses del obj.attr. This is useful for cleanup, logging, or preventing deletion."
The Lookup Order
Margaret pulled up the critical lookup diagram:
"""
ATTRIBUTE LOOKUP ORDER (obj.attr):
1. Check type(obj).__mro__ for 'attr' with a __get__ method
- If it's a data descriptor (has __set__ or __delete__), call __get__ and stop
2. Check obj.__dict__ for 'attr'
- If found, return value and stop
3. Check type(obj).__mro__ for 'attr' again
- If it's a non-data descriptor (only __get__), call __get__ and stop
- If it's a regular attribute, return it and stop
4. If still not found, raise AttributeError
KEY INSIGHTS:
- Data descriptors override instance __dict__
- Instance __dict__ overrides non-data descriptors
- Class attributes checked last
EXAMPLES:
- @property: data descriptor (has __set__, even if read-only)
- methods: non-data descriptors (only __get__)
- Instance attributes: stored in obj.__dict__
"""
def demonstrate_lookup_order():
"""Show the attribute lookup order"""
class DataDesc:
def __get__(self, obj, objtype=None):
return "data descriptor"
def __set__(self, obj, value):
pass
class NonDataDesc:
def __get__(self, obj, objtype=None):
return "non-data descriptor"
class MyClass:
data = DataDesc()
nondata = NonDataDesc()
regular = "regular class attribute"
obj = MyClass()
obj.__dict__['data'] = "instance value for data"
obj.__dict__['nondata'] = "instance value for nondata"
obj.__dict__['regular'] = "instance value for regular"
print("Lookup priority demonstration:")
print(f" obj.data: {obj.data}")
print(f" obj.nondata: {obj.nondata}")
print(f" obj.regular: {obj.regular}\n")
print("What's in instance __dict__:")
print(f" {obj.__dict__}")
print("\n✓ Data descriptor wins over instance dict!")
print("✓ Instance dict wins over non-data descriptor!")
demonstrate_lookup_order()
Output:
Lookup priority demonstration:
obj.data: data descriptor
obj.nondata: instance value for nondata
obj.regular: instance value for regular
What's in instance __dict__:
{'data': 'instance value for data', 'nondata': 'instance value for nondata', 'regular': 'instance value for regular'}
✓ Data descriptor wins over instance dict!
✓ Instance dict wins over non-data descriptor!
"This explains so much!" Timothy exclaimed. "The lookup order determines what takes precedence. That's why properties always work even if you set an instance attribute with the same name."
"Exactly. The lookup order is the key to understanding Python's attribute system."
When NOT to Use Descriptors
Margaret showed boundaries:
"""
WHEN NOT TO USE DESCRIPTORS:
❌ Simple attributes with no logic
- Just use regular attributes
- Don't over-engineer
❌ When @property is sufficient
- Properties are simpler for single attributes
- Only use descriptors when reusing logic
❌ When behavior varies per instance
- Descriptors are class-level
- Instance methods might be better
❌ Performance-critical hot paths
- Descriptor calls add overhead
- Direct attribute access is faster
GOOD USE CASES:
✓ Reusable validation logic
✓ Type checking across many attributes
✓ Lazy evaluation patterns
✓ Access logging/debugging
✓ ORM field definitions
✓ Framework internals
BAD USE CASES:
❌ One-off property with simple getter/setter
❌ Complex instance-specific behavior
❌ Premature optimization
❌ Making code "clever" for no reason
"""
Common Pitfalls
Margaret showed common mistakes:
def demonstrate_pitfalls():
"""Show common descriptor pitfalls"""
print("Pitfall 1: Forgetting to handle obj=None")
class BadDescriptor:
def __get__(self, obj, objtype=None):
return obj.value # Crashes when accessed from class!
class GoodDescriptor:
def __get__(self, obj, objtype=None):
if obj is None:
return self # Return descriptor when accessed from class
return obj.value
print(" Always check if obj is None in __get__\n")
print("Pitfall 2: Name collision in __dict__")
class NameCollision:
# ❌ BAD - Don't do this! Causes infinite recursion!
def __init__(self, name):
self.name = name # Don't use the same name!
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.name) # ❌ Infinite recursion!
def __set__(self, obj, value):
setattr(obj, self.name, value) # ❌ Infinite recursion!
print(" Use a different internal name (like _name)\n")
print("Pitfall 3: Forgetting descriptors are class-level")
class SharedState:
"""This descriptor shares state across ALL instances!"""
def __init__(self):
self.value = 0
def __get__(self, obj, objtype=None):
if obj is None:
return self
return self.value
def __set__(self, obj, value):
self.value = value # Shared across all instances!
class MyClass:
attr = SharedState()
obj1 = MyClass()
obj2 = MyClass()
obj1.attr = 10
print(f" obj1.attr = {obj1.attr}")
print(f" obj2.attr = {obj2.attr}") # Also 10!
print(" ✗ Descriptor state is shared!\n")
print(" Solution: Store per-instance data in obj.__dict__")
demonstrate_pitfalls()
The Magnifying Glass Metaphor
Margaret brought it back to a metaphor:
"Think of descriptors like magnifying glasses that sit between you and an object's attributes.
"Without a descriptor, accessing obj.attr is like looking at an attribute directly - you see what's there in the __dict__.
"With a descriptor, you're looking through a magnifying glass that can:
- Enhance what you see (computed properties)
- Verify what you're looking at (validation)
- Record that you looked (logging)
- Show you something different entirely (transformation)
"The magnifying glass (descriptor) intercepts your access and can customize the entire experience. It's transparent to the user - they just write obj.attr - but powerful for the implementer.
"And the beauty is: descriptors are the mechanism Python itself uses for methods, properties, classmethods, and staticmethods. You're not learning a special trick - you're learning how Python actually works under the hood."
Key Takeaways
Margaret summarized:
"""
DESCRIPTOR KEY TAKEAWAYS:
1. What are descriptors:
- Objects that implement __get__, __set__, or __delete__
- Control what happens when attributes are accessed
- Foundation of properties, methods, classmethods, staticmethods
- Stored as class attributes, not instance attributes
2. The protocol:
- __get__(self, obj, type=None): Called on access (obj.attr)
- __set__(self, obj, value): Called on assignment (obj.attr = value)
- __delete__(self, obj): Called on deletion (del obj.attr)
3. Data vs non-data descriptors:
- Data descriptors: Have __set__ or __delete__ (override instance dict)
- Non-data descriptors: Only have __get__ (instance dict wins)
- This determines lookup priority
4. Lookup order:
- Data descriptors (from class)
- Instance __dict__
- Non-data descriptors (from class)
- Class __dict__
- AttributeError
5. Built-in descriptors:
- @property: data descriptor (has __set__ even if read-only)
- functions/methods: non-data descriptors (binding mechanism)
- @classmethod: descriptor that binds class
- @staticmethod: descriptor that doesn't bind
6. Real-world uses:
- Type validation (enforce types)
- Lazy properties (compute once, cache)
- Access logging (debugging)
- ORM fields (SQLAlchemy, Django)
- Computed properties (derived values)
- Attribute transformation (modify on access)
7. When to use:
- Reusable validation logic
- Cross-cutting concerns (logging, caching)
- Framework/library development
- When @property isn't enough
8. When NOT to use:
- Simple one-off properties
- No reuse needed
- Instance-specific behavior
- Performance-critical paths
9. Common pitfalls:
- Forgetting to handle obj=None in __get__
- Name collision causing infinite recursion
- Sharing state across instances
- Using wrong descriptor type (data vs non-data)
10. Key insights:
- Methods are descriptors (explain 'self' binding)
- @property is just a convenient descriptor
- Descriptors are class-level (shared across instances)
- Lookup order determines what wins
- Most powerful when reused across many attributes
"""
Timothy nodded, everything clicking into place. "So descriptors are the universal mechanism for customizing attribute access in Python. @property is just a descriptor wrapper. Methods bind self through the descriptor protocol. Classmethods and staticmethods are descriptors too. The entire attribute system is built on descriptors - they're not a trick, they're fundamental to how Python works!"
"Perfect understanding," Margaret confirmed. "Descriptors are one of Python's most powerful features because they let you customize the core behavior of attribute access. They're the secret behind properties, methods, and so many frameworks. Most Python developers use them every day without knowing they exist. But now you know the secret - and you can create your own descriptors to solve problems elegantly."
With that knowledge, Timothy could understand how @property really works, why methods automatically get self, how to build reusable validation logic, and how frameworks like Django ORM and SQLAlchemy implement their field systems.
Aaron Rose is a software engineer and technology writer at tech-reader.blog and the author of Think Like a Genius.
Top comments (0)