DEV Community

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

Posted on

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

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

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

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

Output:

Type of 'value': <class 'property'>
Has __get__: True
Has __set__: True
Has __delete__: True

It's a descriptor! <property object at 0x...>
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

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

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

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

Output:

First access:
  Computing expensive_computation...

Second access:
  Using cached expensive_computation

Results are same: True

Accessing different property:
  Computing another_expensive_operation...
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

Output:

Class definition triggers __set_name__:
  Descriptor created for Person.name
  Descriptor created for Person.age

Using the descriptors:
  Alice, age 30
Enter fullscreen mode Exit fullscreen mode

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

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

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 (@property with 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
"""
Enter fullscreen mode Exit fullscreen mode

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

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

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.

Top comments (0)