Python6 min read

Descriptors Explain Python's Attribute Lookup

A step-by-step model for data descriptors, instance dictionaries, methods, and attribute fallbacks.

  • descriptors
  • attribute lookup
  • object model

Methods, properties, slots, and many ORM fields are built on the descriptor protocol. An object stored on a class is a descriptor when its type defines __get__, __set__, or __delete__.

The ordering rules explain why some class attributes override instance state while others can be shadowed.

Data descriptors come first

For a normal instance attribute read, Python conceptually checks:

  1. A data descriptor found on the class or its method resolution order.
  2. The instance dictionary.
  3. A non-data descriptor or ordinary class attribute.
  4. __getattr__ if the preceding lookup raises AttributeError.

A descriptor defining __set__ or __delete__ is a data descriptor, even if those methods only raise an exception.

class Positive:
    def __set_name__(self, owner, name):
        self.storage_name = f'_{name}'

    def __get__(self, instance, owner=None):
        if instance is None:
            return self
        return getattr(instance, self.storage_name)

    def __set__(self, instance, value):
        if value <= 0:
            raise ValueError('must be positive')
        setattr(instance, self.storage_name, value)

Because Positive is a data descriptor, placing a same-named key in an instance’s __dict__ does not bypass it.

Functions bind through __get__

Python functions stored on a class are non-data descriptors. Access through an instance calls the function’s __get__, producing a bound method that supplies the instance as its first argument.

class Greeter:
    def greet(self, name):
        return f'Hello {name}'

Greeter.greet        # function
Greeter().greet      # bound method

Since functions are non-data descriptors, an instance attribute can shadow a method. Assigning instance.greet = callback changes lookup for that instance without changing the class.

A read-only property—one created without a setter—is also a non-data descriptor. Adding a same-named entry directly to instance.__dict__ therefore shadows the property. Supplying a setter upgrades it to data-descriptor precedence.

The receiver and owner carry context

descriptor.__get__(instance, owner) receives instance=None when accessed through the class. Descriptors commonly return themselves in that case, making metadata and configuration available to tooling.

__set_name__ runs during class creation and tells a descriptor which attribute name received it. Assigning a descriptor to a class later does not automatically repeat that hook; call it explicitly or design another setup path.

Custom lookup needs careful delegation

__getattribute__ runs for every ordinary attribute read. An override should normally delegate to super().__getattribute__ to preserve descriptors and avoid recursion. __getattr__ is a fallback invoked only after normal lookup fails.

Calling object.__getattribute__(instance, name) directly still invokes the class’s __getattr__ hook when the name is absent, because __getattr__ is part of the base lookup algorithm. It is therefore enough to implement all fallback behaviour in __getattr__ without considering the caller.

Descriptors are less magical when treated as one ordered lookup protocol. The crucial questions are where the descriptor lives, whether it is data or non-data, and which object is being used as the receiver.