DEV Community

Cover image for The Secret Life of Python: Attribute Lookup Secrets
Aaron Rose
Aaron Rose

Posted on

The Secret Life of Python: Attribute Lookup Secrets

How Python Really Finds Your Attributes (And Why Properties Always Win)


Timothy stared at his screen, genuinely confused. He'd written what seemed like straightforward Python code, but the behavior made no sense.

class Person:
    def __init__(self, name):
        self.name = name

person = Person("Alice")
print(person.name)  # Alice

# Now let's add it to the instance dictionary directly
person.__dict__['name'] = "Bob"
print(person.name)  # Bob - makes sense!
Enter fullscreen mode Exit fullscreen mode

"That works as expected," he muttered. "The instance dictionary stores the value, and we get it back."

But then he tried something with a property:

class Person:
    def __init__(self, name):
        self._name = name

    @property
    def name(self):
        return f"Property: {self._name}"

person = Person("Alice")
print(person.name)  # Property: Alice

# Try to override it via __dict__
person.__dict__['name'] = "Bob"
print(person.name)  # Still "Property: Alice" - WHAT?!
Enter fullscreen mode Exit fullscreen mode

"That... that doesn't make sense!" Timothy exclaimed, catching Margaret's attention. "I just put 'Bob' directly into the instance dictionary, but the property still wins! How is that possible?"

Margaret smiled knowingly. "Ah, you've stumbled upon one of Python's most elegant secrets—the attribute lookup algorithm. What you're seeing isn't magic, Timothy. It's a carefully orchestrated priority system."

"But I thought the instance dictionary was where attributes lived!"

"It is—but it's not the first place Python looks. Come, let me show you the hidden machinery that makes this all work."


The Attribute Lookup Algorithm

"When you write person.name," Margaret began, pulling out a worn reference manual, "Python follows a specific sequence. This isn't arbitrary—it's the foundation of how descriptors, properties, and methods all work together."

She wrote out the algorithm:

# When you access: obj.attr
# Python actually calls: type(obj).__getattribute__(obj, 'attr')
# 
# The default object.__getattribute__ implements this algorithm:

def __getattribute__(obj, name):
    """The actual attribute lookup algorithm (simplified)"""

    # Step 1: Find the attribute in the class hierarchy
    mro = type(obj).__mro__  # Method Resolution Order
    descriptor = None
    dict_get = dict.get  # Optimization

    # Walk the MRO looking for the attribute
    for base in mro:
        base_dict = getattr(base, '__dict__', None)
        if base_dict is not None:
            descriptor = dict_get(base_dict, name)
            if descriptor is not None:
                break

    # Step 2: If we found a data descriptor, call it and return
    if descriptor is not None:
        descriptor_get = getattr(type(descriptor), '__get__', None)
        if descriptor_get is not None:
            # Check if it's a data descriptor (has __set__ or __delete__)
            if (hasattr(type(descriptor), '__set__') or 
                hasattr(type(descriptor), '__delete__')):
                # Data descriptor - call it and return
                return descriptor_get(descriptor, obj, type(obj))

    # Step 3: Check instance __dict__
    obj_dict = getattr(obj, '__dict__', None)
    if obj_dict is not None:
        if name in obj_dict:
            return obj_dict[name]

    # Step 4: If we found a non-data descriptor, call it now
    if descriptor is not None:
        descriptor_get = getattr(type(descriptor), '__get__', None)
        if descriptor_get is not None:
            return descriptor_get(descriptor, obj, type(obj))
        # Found in class dict but not a descriptor
        return descriptor

    # Step 5: Call __getattr__ if it exists
    getattr_method = getattr(type(obj), '__getattr__', None)
    if getattr_method is not None:
        return getattr_method(obj, name)

    # Step 6: Not found anywhere - raise AttributeError
    raise AttributeError(f"'{type(obj).__name__}' object has no attribute '{name}'")
Enter fullscreen mode Exit fullscreen mode

"The key insight," Margaret emphasized, "is that all of this happens inside __getattribute__. Every single attribute access goes through this method. When you write obj.attr, Python calls type(obj).__getattribute__(obj, 'attr'), which then implements this entire algorithm."

Timothy studied the code carefully. "So the order is:

  1. Data descriptors from class (properties, slots with __set__)
  2. Instance __dict__ (regular attributes)
  3. Non-data descriptors from class (methods, classmethod, staticmethod)
  4. Class __dict__ (class variables that aren't descriptors)
  5. __getattr__ (if defined)
  6. AttributeError"

"Exactly! And notice something crucial—the algorithm walks the MRO (Method Resolution Order) when looking in class dictionaries. This is how inheritance works with descriptors."

class Base:
    @property
    def name(self):
        return "Base"

class Child(Base):
    pass

obj = Child()
print(obj.name)  # "Base" - found by walking MRO
print(Child.__mro__)  # (<class 'Child'>, <class 'Base'>, <class 'object'>)
Enter fullscreen mode Exit fullscreen mode

"So that's why properties override instance attributes," Timothy realized. "They're data descriptors, which are checked before __dict__!"

"Precisely. Now let's see what makes something a descriptor in the first place."


The Descriptor Protocol

Margaret opened a dusty tome titled "The Descriptor Protocol: Python's Hidden Interface."

"A descriptor is any object whose class defines at least one of these three methods:"

class Descriptor:
    def __get__(self, obj, objtype=None):
        """Called when attribute is accessed"""
        pass

    def __set__(self, obj, value):
        """Called when attribute is assigned"""
        pass

    def __delete__(self, obj):
        """Called when attribute is deleted"""
        pass
Enter fullscreen mode Exit fullscreen mode

"The presence of these methods determines the descriptor's behavior:

  • Non-data descriptor: Only __get__ defined
  • Data descriptor: Has __get__ plus (__set__ OR __delete__)

The distinction matters because data descriptors have priority over instance dictionaries."

"Let me show you a simple descriptor:"

class LoggedAccess:
    """A descriptor that logs all access"""

    def __init__(self, name):
        self.name = name
        self.storage_name = f'_{name}'

    def __get__(self, obj, objtype=None):
        if obj is None:  # Called on class
            return self
        value = getattr(obj, self.storage_name)
        print(f"Accessing {self.name}: {value}")
        return value

    def __set__(self, obj, value):
        print(f"Setting {self.name} to {value}")
        setattr(obj, self.storage_name, value)

class Person:
    name = LoggedAccess('name')

    def __init__(self, name):
        self.name = name  # Triggers __set__

person = Person("Alice")
# Setting name to Alice

print(person.name)
# Accessing name: Alice
# Alice

# Try to override via __dict__
person.__dict__['name'] = "Bob"
print(person.name)
# Accessing name: Alice  (descriptor still wins!)
Enter fullscreen mode Exit fullscreen mode

"Even though we put 'Bob' in __dict__, the descriptor wins because it's a data descriptor—it has both __get__ and __set__."

Timothy's eyes widened. "So that's the mechanism! Data descriptors get priority."

"Indeed. Now let's look at Python's most famous descriptor..."


Properties: The Elegant Descriptor

"Properties," Margaret said warmly, "are simply pre-built descriptors with a beautiful interface."

class Temperature:
    def __init__(self, celsius):
        self._celsius = celsius

    @property
    def celsius(self):
        """Get the temperature in Celsius"""
        return self._celsius

    @celsius.setter
    def celsius(self, value):
        """Set the temperature in Celsius"""
        if value < -273.15:
            raise ValueError("Temperature below absolute zero!")
        self._celsius = value

    @property
    def fahrenheit(self):
        """Get the temperature in Fahrenheit"""
        return self._celsius * 9/5 + 32

    @fahrenheit.setter
    def fahrenheit(self, value):
        """Set the temperature via Fahrenheit"""
        celsius = (value - 32) * 5/9
        self.celsius = celsius  # Reuse validation

temp = Temperature(20)
print(temp.celsius)      # 20
print(temp.fahrenheit)   # 68.0

temp.fahrenheit = 86
print(temp.celsius)      # 30.0

try:
    temp.celsius = -300
except ValueError as e:
    print(e)  # Temperature below absolute zero!
Enter fullscreen mode Exit fullscreen mode

"The @property decorator creates a data descriptor. Here's approximately how it works (the actual implementation is in C and more optimized, but this captures the essence):"

class Property:
    """Simplified property implementation (actual is in C)"""

    def __init__(self, fget=None, fset=None, fdel=None, doc=None):
        self.fget = fget
        self.fset = fset
        self.fdel = fdel
        self.__doc__ = doc if doc is not None else fget.__doc__

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        if self.fget is None:
            raise AttributeError("unreadable attribute")
        return self.fget(obj)

    def __set__(self, obj, value):
        if self.fset is None:
            raise AttributeError("can't set attribute")
        self.fset(obj, value)

    def __delete__(self, obj):
        if self.fdel is None:
            raise AttributeError("can't delete attribute")
        self.fdel(obj)

    def getter(self, fget):
        return type(self)(fget, self.fset, self.fdel, self.__doc__)

    def setter(self, fset):
        return type(self)(self.fget, fset, self.fdel, self.__doc__)

    def deleter(self, fdel):
        return type(self)(self.fget, self.fset, fdel, self.__doc__)

# When you write:
# @property
# def name(self):
#     return self._name
#
# Python transforms it to:
# name = Property(name)
Enter fullscreen mode Exit fullscreen mode

Note: This is a simplified Python equivalent of the actual C implementation. The real property class is implemented in C for performance and includes additional optimizations and error handling.

"Notice that having both __get__ and __set__ makes it a data descriptor—even if the setter just raises an error for read-only properties!"


Functions Are Descriptors Too

"Here's something that surprises many," Margaret said with a twinkle in her eye. "Methods aren't special. They're just functions that happen to be descriptors."

class Demo:
    def method(self):
        return f"Called on {self}"

# The function object IS a descriptor
print(hasattr(Demo.method, '__get__'))  # True
print(hasattr(Demo.method, '__set__'))  # False (non-data descriptor!)

# When accessed on the class, we get the function
print(Demo.method)  # <function Demo.method at 0x...>

# When accessed on an instance, __get__ is called
obj = Demo()
bound_method = obj.method
print(bound_method)  # <bound method Demo.method of <Demo object>>
print(bound_method.__self__)  # <Demo object>
print(bound_method.__func__)  # <function Demo.method at 0x...>
Enter fullscreen mode Exit fullscreen mode

"The function's __get__ method creates a bound method—a wrapper that remembers both the function and the instance."

She demonstrated the mechanism:

class Function:
    """Simplified function descriptor (actual is in C)"""

    def __init__(self, func):
        self.func = func

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self  # Accessed on class
        # Create bound method
        return MethodType(self.func, obj)

from types import MethodType

class Person:
    def __init__(self, name):
        self.name = name

    def greet(self):
        return f"Hello, I'm {self.name}"

# Accessing on class returns function
print(Person.greet)  # <function Person.greet>

# Accessing on instance creates bound method via __get__
person = Person("Alice")
bound = person.greet
print(bound)         # <bound method Person.greet of <Person object>>
print(bound())       # Hello, I'm Alice

# The bound method has references to both
print(bound.__self__)  # <Person object>
print(bound.__func__)  # <function Person.greet>

# You can even call the function directly with explicit self
print(Person.greet(person))  # Hello, I'm Alice
Enter fullscreen mode Exit fullscreen mode

"This is why self exists! When you call obj.method(), Python:

  1. Looks up method in the class
  2. Finds a function (non-data descriptor)
  3. Calls its __get__, which returns a bound method
  4. Calls the bound method, which calls the function with self automatically filled in"

Timothy nodded slowly. "So methods are just functions whose __get__ method creates bound methods..."

"Exactly! And because functions are non-data descriptors (only __get__, no __set__), you can override them in the instance dictionary:"

class Demo:
    def method(self):
        return "original"

obj = Demo()
print(obj.method())  # original

# Override in instance dict (works because function is non-data descriptor!)
obj.__dict__['method'] = lambda: "overridden"
print(obj.method())  # overridden
Enter fullscreen mode Exit fullscreen mode

Practical Descriptor Patterns

Pattern 1: Type Validation

"One of the most common uses of descriptors is enforcing type constraints:"

class TypedProperty:
    """A descriptor that enforces type checking"""

    def __init__(self, name, expected_type):
        self.name = name
        self.expected_type = expected_type
        self.storage_name = f'_{name}'

    def __set_name__(self, owner, name):
        # Called when descriptor is assigned to class attribute
        # This happens once, during class creation (not per instance)
        self.name = name
        self.storage_name = f'_{name}'

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return getattr(obj, self.storage_name, None)

    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__}"
            )
        setattr(obj, self.storage_name, value)

class Person:
    name = TypedProperty('name', str)
    age = TypedProperty('age', int)

    def __init__(self, name, age):
        self.name = name
        self.age = age

# Works fine
person = Person("Alice", 30)
print(f"{person.name} is {person.age}")  # Alice is 30

# Type checking in action
try:
    person.age = "thirty"
except TypeError as e:
    print(e)  # age must be int, got str

try:
    person.name = 123
except TypeError as e:
    print(e)  # name must be str, got int
Enter fullscreen mode Exit fullscreen mode

Note: The __set_name__ method is called once during class creation, not when creating instances. When Python creates the Person class, it calls __set_name__ on each descriptor to let it know its attribute name.

Pattern 2: Lazy Computation with Caching

"Descriptors can compute values on first access and cache them:"

class CachedProperty:
    """A property that computes once and caches the result"""

    def __init__(self, func):
        self.func = func
        self.name = func.__name__

    def __set_name__(self, owner, name):
        self.name = name

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self

        # Compute the value
        value = self.func(obj)

        # Store in instance __dict__ - this makes it override the descriptor!
        # Next access will find it in __dict__ (checked before non-data descriptors)
        obj.__dict__[self.name] = value

        return value

class DataProcessor:
    def __init__(self, numbers):
        self.numbers = numbers

    @CachedProperty
    def total(self):
        """Expensive computation - only do it once"""
        print("Computing total...")
        return sum(self.numbers)

    @CachedProperty
    def average(self):
        """Another expensive computation"""
        print("Computing average...")
        return self.total / len(self.numbers)

processor = DataProcessor([1, 2, 3, 4, 5])

print(processor.total)    # Computing total... \n 15
print(processor.total)    # 15 (no recomputation!)

print(processor.average)  # Computing average... \n 3.0
print(processor.average)  # 3.0 (cached!)

# The values are now in __dict__
print(processor.__dict__)  # {'numbers': [1,2,3,4,5], 'total': 15, 'average': 3.0}
Enter fullscreen mode Exit fullscreen mode

"Notice the clever trick: CachedProperty is a non-data descriptor (only __get__), so after it puts the value in __dict__, the instance dictionary takes precedence on subsequent accesses!"

Python 3.8+ includes this pattern in the standard library:

from functools import cached_property

class DataProcessor:
    def __init__(self, numbers):
        self.numbers = numbers

    @cached_property
    def total(self):
        print("Computing total...")
        return sum(self.numbers)

processor = DataProcessor([1, 2, 3, 4, 5])
print(processor.total)    # Computing total... \n 15
print(processor.total)    # 15 (cached!)
Enter fullscreen mode Exit fullscreen mode

Note: The custom CachedProperty implementation shown above is not thread-safe. For production code with threading, use functools.cached_property which includes proper locking mechanisms.

Pattern 3: Attribute Aliasing

"Descriptors can create aliases or computed attributes:"

class Alias:
    """Create an alias to another attribute"""

    def __init__(self, target_name):
        self.target_name = target_name

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return getattr(obj, self.target_name)

    def __set__(self, obj, value):
        setattr(obj, self.target_name, value)

class Person:
    def __init__(self, full_name):
        self.full_name = full_name

    # Create an alias
    name = Alias('full_name')

person = Person("Alice Wonderland")
print(person.name)       # Alice Wonderland
print(person.full_name)  # Alice Wonderland

person.name = "Bob Builder"
print(person.full_name)  # Bob Builder
Enter fullscreen mode Exit fullscreen mode

Real-World Example: Django's Model Fields

"Let's look at how Django uses descriptors for database fields," Margaret suggested, pulling out a Django models reference.

class Field:
    """Simplified Django field descriptor"""

    def __init__(self, default=None):
        self.default = default
        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 value from instance dict, or default
        return obj.__dict__.get(self.name, self.default)

    def __set__(self, obj, value):
        # Validate and store
        obj.__dict__[self.name] = self.validate(value)

    def validate(self, value):
        """Override in subclasses"""
        return value

class CharField(Field):
    def __init__(self, max_length, **kwargs):
        super().__init__(**kwargs)
        self.max_length = max_length

    def validate(self, value):
        if value is None:
            return value
        if not isinstance(value, str):
            raise TypeError(f"Expected str, got {type(value).__name__}")
        if len(value) > self.max_length:
            raise ValueError(f"String too long (max {self.max_length})")
        return value

class IntegerField(Field):
    def validate(self, value):
        if value is None:
            return value
        if not isinstance(value, int):
            raise TypeError(f"Expected int, got {type(value).__name__}")
        return value

# Using the fields (like Django models)
class User:
    username = CharField(max_length=50)
    age = IntegerField(default=0)

    def __init__(self, username, age=0):
        self.username = username
        self.age = age

user = User("alice", 30)
print(user.username)  # alice
print(user.age)       # 30

# Validation in action
try:
    user.username = "a" * 100
except ValueError as e:
    print(e)  # String too long (max 50)

try:
    user.age = "thirty"
except TypeError as e:
    print(e)  # Expected int, got str
Enter fullscreen mode Exit fullscreen mode

"This pattern is at the heart of Django's ORM. Each model field is a descriptor that handles validation, type conversion, and database mapping."


slots: The Space-Saving Descriptor

"There's one more important descriptor built into Python," Margaret noted. "Slots."

class Regular:
    def __init__(self, x, y):
        self.x = x
        self.y = y

class WithSlots:
    __slots__ = ('x', 'y')

    def __init__(self, x, y):
        self.x = x
        self.y = y

# Regular class has __dict__
regular = Regular(1, 2)
print(hasattr(regular, '__dict__'))  # True
print(regular.__dict__)  # {'x': 1, 'y': 2}

# Slotted class doesn't have __dict__
slotted = WithSlots(1, 2)
print(hasattr(slotted, '__dict__'))  # False

# Can't add arbitrary attributes
try:
    slotted.z = 3
except AttributeError as e:
    print(e)  # 'WithSlots' object has no attribute 'z'

# But declared slots work fine
print(slotted.x)  # 1
slotted.x = 10
print(slotted.x)  # 10
Enter fullscreen mode Exit fullscreen mode

"Each slot is actually a data descriptor created by Python:"

class WithSlots:
    __slots__ = ('x', 'y')

# Python creates descriptors for each slot
print(type(WithSlots.x))  # <class 'member_descriptor'>
print(hasattr(WithSlots.x, '__get__'))  # True
print(hasattr(WithSlots.x, '__set__'))  # True - data descriptor!
print(hasattr(WithSlots.x, '__delete__'))  # True

# The descriptor stores values in a fixed location
obj = WithSlots()
obj.x = 42
# The descriptor's __set__ stores 42 in a fixed memory slot

# Since slots are data descriptors, they override __dict__
# (if __dict__ were present, which it isn't with slots)
Enter fullscreen mode Exit fullscreen mode

"Slots save memory by eliminating __dict__ and using descriptors to access fixed memory locations instead."


getattribute vs getattr: The Safety Net

"We've seen how __getattribute__ implements the standard lookup algorithm," Margaret said. "But there's also __getattr__—a fallback hook."

class Fallback:
    def __init__(self):
        self.existing = "I exist"

    def __getattr__(self, name):
        """Called only when normal lookup fails"""
        print(f"__getattr__ called for: {name}")
        return f"Generated: {name}"

obj = Fallback()

# Normal lookup succeeds - __getattr__ not called
print(obj.existing)  # I exist

# Normal lookup fails - __getattr__ called
print(obj.missing)   
# __getattr__ called for: missing
# Generated: missing
Enter fullscreen mode Exit fullscreen mode

"But __getattribute__ is different—it's called for every attribute access:"

class Monitor:
    def __init__(self):
        # Use object.__setattr__ to avoid recursion
        object.__setattr__(self, 'existing', 'I exist')

    def __getattribute__(self, name):
        """Called for EVERY attribute access"""
        print(f"__getattribute__ called for: {name}")
        # Must call parent to do actual lookup
        return object.__getattribute__(self, name)

obj = Monitor()

print(obj.existing)
# __getattribute__ called for: existing
# I exist

try:
    print(obj.missing)
    # __getattribute__ called for: missing
    # AttributeError: 'Monitor' object has no attribute 'missing'
except AttributeError as e:
    print(e)
Enter fullscreen mode Exit fullscreen mode

"Be very careful with __getattribute__—it's easy to create infinite recursion:"

class Dangerous:
    def __init__(self):
        self.value = 42

    def __getattribute__(self, name):
        print(f"Getting {name}")
        # DON'T DO THIS! Causes infinite recursion
        # return self.value  # This calls __getattribute__ again!

        # DO THIS instead:
        return object.__getattribute__(self, name)

obj = Dangerous()
print(obj.value)
# Getting value
# 42
Enter fullscreen mode Exit fullscreen mode

"The key difference:

  • __getattribute__: Called for every access (implement carefully!)
  • __getattr__: Called only when normal lookup fails (safe fallback)"

Practical Use: Proxy Pattern

class LazyProxy:
    """Proxy that creates wrapped object on first access"""

    def __init__(self, factory):
        # Store factory without triggering __setattr__
        object.__setattr__(self, '_factory', factory)
        object.__setattr__(self, '_wrapped', None)

    def _ensure_wrapped(self):
        """Create wrapped object if needed"""
        if object.__getattribute__(self, '_wrapped') is None:
            factory = object.__getattribute__(self, '_factory')
            wrapped = factory()
            object.__setattr__(self, '_wrapped', wrapped)

    def __getattribute__(self, name):
        # Handle special attributes
        if name in ('_factory', '_wrapped', '_ensure_wrapped'):
            return object.__getattribute__(self, name)

        # Ensure wrapped object exists
        object.__getattribute__(self, '_ensure_wrapped')()

        # Delegate to wrapped object
        wrapped = object.__getattribute__(self, '_wrapped')
        return getattr(wrapped, name)

# Usage
class Expensive:
    def __init__(self):
        print("Creating expensive object...")
        self.data = "expensive data"

    def process(self):
        return f"Processing: {self.data}"

# Create proxy - doesn't create Expensive yet
proxy = LazyProxy(lambda: Expensive())
print("Proxy created")

# First access creates the wrapped object
print(proxy.process())
# Creating expensive object...
# Processing: expensive data

# Subsequent access reuses wrapped object
print(proxy.process())
# Processing: expensive data
Enter fullscreen mode Exit fullscreen mode

Debugging Attribute Lookup

"When debugging descriptor issues," Margaret advised, "use these tools:"

import inspect

class MyClass:
    class_var = "class variable"

    def __init__(self):
        self.instance_var = "instance variable"

    @property
    def prop(self):
        return "property value"

    def method(self):
        return "method result"

obj = MyClass()

# Check if something is a descriptor
def is_descriptor(obj):
    return (hasattr(type(obj), '__get__') or 
            hasattr(type(obj), '__set__') or
            hasattr(type(obj), '__delete__'))

# Check if it's a data descriptor
def is_data_descriptor(obj):
    return (hasattr(type(obj), '__get__') and
            (hasattr(type(obj), '__set__') or 
             hasattr(type(obj), '__delete__')))

# Inspect class attributes
for name, value in inspect.getmembers(MyClass):
    if not name.startswith('_'):
        is_desc = is_descriptor(value)
        is_data = is_data_descriptor(value)
        print(f"{name:15} descriptor={is_desc:5} data={is_data:5} type={type(value).__name__}")

# Output:
# class_var       descriptor=False data=False type=str
# method          descriptor=True  data=False type=function
# prop            descriptor=True  data=True  type=property

# Check instance __dict__
print(f"\nInstance __dict__: {obj.__dict__}")
# {'instance_var': 'instance variable'}

# Trace lookup manually
def trace_lookup(obj, attr):
    """Show where an attribute comes from"""
    print(f"\nLooking up '{attr}' on {type(obj).__name__}:")

    # Check class hierarchy for descriptors
    for cls in type(obj).__mro__:
        if attr in cls.__dict__:
            value = cls.__dict__[attr]
            if is_data_descriptor(value):
                print(f"  ✓ Found data descriptor in {cls.__name__}.__dict__")
                return
            else:
                print(f"  - Found in {cls.__name__}.__dict__ (will check instance first)")
                break

    # Check instance __dict__
    if hasattr(obj, '__dict__') and attr in obj.__dict__:
        print(f"  ✓ Found in instance.__dict__")
        return

    # Check for non-data descriptors
    for cls in type(obj).__mro__:
        if attr in cls.__dict__:
            value = cls.__dict__[attr]
            if is_descriptor(value):
                print(f"  ✓ Found non-data descriptor in {cls.__name__}.__dict__")
            else:
                print(f"  ✓ Found in {cls.__name__}.__dict__")
            return

    print(f"  ✗ Not found - would raise AttributeError")

trace_lookup(obj, 'prop')           # Data descriptor
trace_lookup(obj, 'instance_var')   # Instance dict
trace_lookup(obj, 'method')         # Non-data descriptor
trace_lookup(obj, 'class_var')      # Class dict
trace_lookup(obj, 'missing')        # Not found
Enter fullscreen mode Exit fullscreen mode

Conclusion: The Hidden Orchestra

Timothy leaned back, his confusion replaced by understanding. "So when I write person.name, Python:

  1. Calls type(person).__getattribute__(person, 'name')
  2. Which walks the MRO looking for 'name' in class dictionaries
  3. If found and it's a data descriptor, calls its __get__ and returns
  4. Otherwise checks person.__dict__
  5. If not there, checks for non-data descriptors in the class
  6. Falls back to __getattr__ if defined
  7. Finally raises AttributeError if nothing found

And this whole system enables properties, methods, slots, and custom descriptors to all work together!"

"Precisely," Margaret smiled. "The descriptor protocol is Python's way of giving you control over attribute access without sacrificing the simple obj.attr syntax we all love."

"Properties are just data descriptors that happen to look nice with the @property decorator. Methods are functions whose __get__ creates bound methods. Slots are descriptors created by Python to save memory. And you can write your own descriptors for validation, lazy computation, aliasing—anything you need."

"The beauty," she continued, "is that this all happens invisibly. Most Python programmers use properties and methods every day without knowing about descriptors. But now you understand the machinery beneath."

Timothy nodded, closing his notebook. "And that's why properties always win over __dict__—they're checked first in the lookup order."

"Exactly. The simple mystery you discovered led us to one of Python's most elegant designs." Margaret gestured to the shelves around them. "This library is full of such discoveries. Every time you think you understand Python, there's another layer waiting."

"Speaking of layers," Timothy said with a grin, "I'm curious about that super() function. How does it navigate the MRO?"

Margaret's eyes twinkled. "Ah, Timothy. That's a story for another day..."


Key Takeaways

  1. Attribute access calls __getattribute__, which implements the lookup algorithm
  2. The MRO is walked when looking for descriptors in class dictionaries
  3. Data descriptors (with __get__ and __set__ or __delete__) override instance __dict__
  4. Instance __dict__ is checked after data descriptors but before non-data descriptors
  5. Non-data descriptors (only __get__) are checked after instance __dict__
  6. Properties are data descriptors with a nice syntax
  7. Functions are non-data descriptors that create bound methods
  8. Slots are data descriptors that save memory
  9. __getattr__ is only called when normal lookup fails
  10. __getattribute__ is called for every attribute access (use carefully!)

Next in The Secret Life of Python: "super() and the Method Resolution Order"


About This Series

The Secret Life of Python reveals the hidden mechanisms that make Python work. Through the adventures of Timothy (a curious developer) and Margaret (a wise Python expert) in a vast library, we explore the elegant designs beneath Python's simple syntax.

Each article stands alone but builds on previous knowledge. Whether you're debugging mysterious behavior or just curious about how Python really works, this series illuminates the path.

Previous articles include The Descriptor Protocol Basics, Method Resolution Order, Metaclasses Demystified, and more.


Discussion Questions

  1. When would you choose a descriptor over a property?
  2. How do Django's model fields use descriptors for database mapping?
  3. What are the trade-offs of using __slots__?
  4. When is __getattribute__ worth the complexity?
  5. How do descriptors relate to the decorator pattern?

Share your thoughts and experiences with descriptors in the comments below!


Aaron Rose is a software engineer and technology writer at tech-reader.blog and the author of Think Like a Genius.

Top comments (0)