DEV Community

Cover image for The Secret Life of Python: Descriptor Secrets - How Properties Really Work
Aaron Rose
Aaron Rose

Posted on

The Secret Life of Python: Descriptor Secrets - How Properties Really Work

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!
Enter fullscreen mode Exit fullscreen mode

"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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

Output:

Using our property descriptor:
  Getting celsius
  temp.celsius = 25
  Setting celsius to 30
  Getting celsius
  temp.celsius = 30

✓ We built our own @property!
Enter fullscreen mode Exit fullscreen mode

"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()
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

"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()
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

"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()
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

"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()
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

"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()
Enter fullscreen mode Exit fullscreen mode

Output:

Creating account and performing operations:
  [SET] balance = 1000
  [GET] balance = 1000
  [SET] balance = 1500
  [GET] balance = 1500

✓ All attribute access is logged!
Enter fullscreen mode Exit fullscreen mode

"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()
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

"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()
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

"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
"""
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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
"""
Enter fullscreen mode Exit fullscreen mode

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)