The Descriptor Protocol & Attribute Resolution

Master the Python descriptor protocol: Data vs Non-Data descriptors, the 6-step attribute resolution pipeline, method binding, and custom validation descriptors.

03. Object-Oriented Systems, Descriptor Protocol & Metaprogramming8 min read

The descriptor protocol is the foundational mechanism powering Python's object model. Properties (@property), bound methods, class methods (@classmethod), static methods (@staticmethod), and __slots__ are all direct applications of descriptors.

A descriptor is any object that customizes attribute access, assignment, and deletion by implementing one or more protocol methods: __get__, __set__, and __delete__.

Attribute ResolutionRuntime Resolution
6 Stages

CPython executes a strict 6-stage precedence pipeline when evaluating obj.attr.

1. Data vs Non-Data Descriptors#

Python classifies descriptors into two distinct categories based on which protocol methods they implement:

Descriptor CategoryRequired MethodsKey CharacteristicExamples
Data DescriptorImplements __get__ AND (__set__ OR __delete__)Overrides instance dictionary attributes with the same name!@property, __slots__ members, ORM database fields
Non-Data DescriptorImplements ONLY __get__Yields precedence to instance dictionary attributes if presentStandard function objects (methods), @staticmethod, @classmethod

2. The 6-Stage Attribute Resolution Pipeline#

When an expression like obj.attr is evaluated on an instance obj of class Cls, CPython's C-API function PyObject_GenericGetAttr() executes the following resolution pipeline:

D.\text{\_\_get\_\_}(obj, Cls) & \text{if } attr \in Cls.\text{\_\_mro\_\_} \text{ as Data Descriptor } D \\ obj.\text{\_\_dict\_\_}[attr] & \text{else if } attr \in obj.\text{\_\_dict\_\_} \\ ND.\text{\_\_get\_\_}(obj, Cls) & \text{else if } attr \in Cls.\text{\_\_mro\_\_} \text{ as Non-Data Descriptor } ND \\ Cls.\text{\_\_mro\_\_}[attr] & \text{else if } attr \in Cls.\text{\_\_mro\_\_} \text{ as standard class attribute} \\ Cls.\text{\_\_getattr\_\_}(obj, attr) & \text{else if } \text{\_\_getattr\_\_} \in Cls.\text{\_\_mro\_\_} \\ \bot \; (\text{AttributeError}) & \text{otherwise}
Architecture Diagram
Rendering diagram...
Why Data Descriptors Trump Instance Dict
Because Data Descriptors have higher precedence than instance dictionaries, assigning obj.__dict__['x'] = 10 will not shadow or override a property or data descriptor named x! CPython always queries data descriptors first.

3. How Functions Become Bound Methods#

Ever wonder why regular Python functions require self as their first parameter when called on an instance?

In Python, all standard function objects implement __get__, making every function a Non-Data Descriptor!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def calculate_area(self): return self.width * self.height class Rectangle: area = calculate_area # Assign function directly to class attribute rect = Rectangle() rect.width = 10 rect.height = 5 # Function.__get__ binds instance rect to self: bound_method = Rectangle.area.__get__(rect, Rectangle) print(f"Bound Method Result: {bound_method()}") # Output: 50 print(f"Calling rect.area(): {rect.area()}") # Output: 50

When you evaluate rect.area():

  1. rect.__dict__ has no area key.
  2. CPython finds calculate_area in Rectangle.__dict__.
  3. CPython detects that the function implements __get__.
  4. It calls calculate_area.__get__(rect, Rectangle), which returns a types.MethodType proxy wrapping both the function and the instance rect.
  5. Calling the proxy invokes calculate_area(rect).

4. Building a Reusable Validation Descriptor#

Descriptors shine when abstracting cross-cutting attribute validation logic across multiple domain classes:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class PositiveInteger: """Reusable Data Descriptor enforcing positive integer constraints.""" def __set_name__(self, owner, name): # Automatically captures attribute name (Python 3.6+) self.storage_name = f"_{name}" def __get__(self, instance, owner=None): if instance is None: # Accessed from class: return descriptor instance itself return self return getattr(instance, self.storage_name, 0) def __set__(self, instance, value): if not isinstance(value, int) or value <= 0: raise ValueError(f"{self.storage_name[1:]} must be a positive integer, got {value}") setattr(instance, self.storage_name, value) def __delete__(self, instance): raise AttributeError("Cannot delete this validated attribute") class BankAccount: balance = PositiveInteger() account_number = PositiveInteger() def __init__(self, acc_num: int, opening_balance: int): self.account_number = acc_num self.balance = opening_balance account = BankAccount(1001, 500) print(f"Account balance: {account.balance}") # 500 try: account.balance = -50 except ValueError as e: print(f"Caught validation error: {e}")

5. Concept Verification#

Test your understanding of descriptors and attribute resolution:

Concept check

What defines the operational difference between a Data Descriptor and a Non-Data Descriptor in Python?

Concept check

How does Python transform a standalone function into a bound method when accessed on an instance?