Building Python Descriptors: Architecture, Internals, and Best Practices

Building Python Descriptors: Architecture, Internals, and Best Practices

A deep-dive guide to Python attribute lookup — covering the descriptor protocol, data vs non-data descriptors, CPython resolution algorithms, properties, and ORM field validation.

Whenever you access an attribute in Python using dot notation (like obj.name or obj.method()), Python executes a sophisticated lookup algorithm under the hood.

Attribute lookup is not a simple key-value fetch from obj.__dict__. Instead, Python relies on a low-level protocol called **Descriptors**. Descriptors power Python's core object-oriented features: `@property`, `@classmethod`, `@staticmethod`, and ORM model fields (like Django or SQLAlchemy models). Understanding descriptors allows developers to eliminate boilerplate validation code, build robust API interfaces, and write high-performance Python frameworks. This guide traces descriptor architecture, explores data vs non-data descriptor priorities, implements custom validators, details CPython lookup algorithms, and simulates attribute resolution in our interactive emulator.


1. The Attribute Lookup Engine: What Happens When You Type `obj.attr`?

1.1 Dot Notation Mechanics

In Python, attributes appear to sit inside an object's instance dictionary (obj.__dict__). If you write obj.x = 10, Python stores {'x': 10} in obj.__dict__. However, when you query obj.x, Python does not check obj.__dict__ first in all scenarios.

If a class defines x as a **Data Descriptor** (an object implementing __get__ and __set__), Python overrides the instance dictionary entirely. This descriptor interception is what allows `@property` to run custom getter logic transparently when accessed like a standard variable.

1.2 Direct Dict Access vs Descriptor Interception

Direct dictionary access reads raw keys from __dict__. Descriptor interception invokes dunder methods on class-level objects, allowing dynamic computation, validation, and lazy loading during attribute reads and writes.

Common Misconception — Descriptors are instantiated on object instances: A common developer misconception is that descriptor objects are created per instance. In reality, descriptors MUST be declared as class-level attributes. All instances of a class share the same descriptor object. Storing instance state directly inside a descriptor attribute (e.g. self.val = val inside the descriptor) will corrupt data across all instances.


2. The Descriptor Protocol: `__get__`, `__set__`, and `__delete__`

2.1 The Core Protocol Methods

An object is defined as a Descriptor if it implements at least one of the three core descriptor protocol methods:

  • __get__(self, instance, owner=None): Called when the attribute is read. instance is the object instance (e.g. user), and owner is the class (e.g. User).
  • __set__(self, instance, value): Called when the attribute is assigned a value (e.g. user.age = 25).
  • __delete__(self, instance): Called when the attribute is deleted (e.g. del user.age).
graph TD AttrCall["Dot Notation: obj.attr"] CheckDesc{"Is class.attr a Descriptor?"} ExecGet["Invoke descriptor.__get__(obj, Class)"] FetchDict["Fetch from obj.__dict__['attr']"] AttrCall --> CheckDesc CheckDesc -- "Yes" --> ExecGet CheckDesc -- "No" --> FetchDict

Mermaid Diagram: High-level branching decision during Python attribute reads.


3. Data Descriptors vs Non-Data Descriptors

3.1 Priority Classifications

Descriptors are categorized into two distinct types based on which protocol methods they implement:

  • **Data Descriptors**: Define both __get__ and __set__ (or __delete__). Data descriptors take precedence over instance __dict__ keys. Even if obj.__dict__['x'] exists, obj.x will execute the data descriptor's __get__.
  • **Non-Data Descriptors**: Define only __get__. If an entry with the same name exists in obj.__dict__, the instance dictionary entry takes precedence, shadowing the non-data descriptor. Functions and methods in Python are non-data descriptors.

4. Demystifying Built-in Decorators: Property, Classmethod, and Staticmethod

4.1 Pure Python Implementations

Python's `@property`, `@classmethod`, and `@staticmethod` decorators are built using the descriptor protocol. Below is how `@staticmethod` is implemented in pure Python:

class PureStaticMethod:
    def __init__(self, f):
        self.f = f
 
    def __get__(self, instance, owner=None):
        return self.f # Return raw underlying function without binding 'self'

Because `@staticmethod` returns the raw function without binding the instance, calling obj.static_func() passes no implicit self argument.

4.2 Pure Python ClassMethod and Property Descriptors

To understand how `@classmethod` and `@property` operate, we can write their equivalent pure Python descriptor logic. For `@classmethod`, the descriptor must bind the class (the `owner` argument) as the first argument, regardless of whether it is called from an instance or from the class itself:

import types
 
class PureClassMethod:
    def __init__(self, f):
        self.f = f
 
    def __get__(self, instance, owner=None):
        if owner is None:
            owner = type(instance)
        # Bind the owner class as the first parameter (cls)
        return types.MethodType(self.f, owner)

Similarly, the `@property` decorator is a full Data Descriptor storing references to getter, setter, and deleter functions. When defined, it manages access calls dynamically:

class PureProperty:
    def __init__(self, fget=None, fset=None, fdel=None):
        self.fget = fget
        self.fset = fset
        self.fdel = fdel
 
    def __get__(self, instance, owner=None):
        if instance is None:
            return self
        if self.fget is None:
            raise AttributeError("Unreadable attribute")
        return self.fget(instance)
 
    def __set__(self, instance, value):
        if self.fset is None:
            raise AttributeError("Can't set attribute")
        self.fset(instance, value)
 
    def setter(self, fset):
        return type(self)(self.fget, fset, self.fdel)

This demonstrates how Python unifies functions, properties, and class methods under the single descriptor protocol. Below is a comparison table of built-in decorators:

Decorator Descriptor Type First Argument Passed Primary System Function
`@staticmethod` Non-Data Descriptor None (no implicit self or cls) Utility functions associated with class namespace
`@classmethod` Non-Data Descriptor `cls` (the owner class object) Factory constructors and class-wide configuration
`@property` Data Descriptor `self` (the instance object) Computed properties and attribute access validation

Pitfall — Forgetting return statements in @property getters: If you omit the `return` statement inside a property getter, Python silently returns `None` during attribute reads. Always return the computed value explicitly.


5. Validation and Type Checking Descriptors

5.1 Eliminating Boilerplate Setters

Descriptors are ideal for enforcing attribute constraints across multiple classes. Instead of writing duplicate `@property` getters and setters for every validated attribute, we can encapsulate type-checking logic in a single reusable Data Descriptor class.

When a value is assigned, the descriptor's __set__ method intercepts the call, validates the type or value bounds, and stores the validated value in the instance's private __dict__ storage.

5.2 Validation Pipeline Chains and Lazy Loading Mathematics

In modern Python frameworks (like Pydantic, Django ORM, or SQLAlchemy), descriptors form the core of the validation and dynamic loading pipeline. When an attribute is assigned a value $v$, the data descriptor executes a sequential validation pipeline $\mathcal{V}(v)$ composed of discrete validation stages:

$$ \mathcal{V}(v) = f_n \circ f_{n-1} \circ \dots \circ f_1(v) $$

Let $f_1(v)$ be the Type Verification stage, $f_2(v)$ be the Range/Bounds Check stage, and $f_3(v)$ be the Sanitization stage. If any function $f_k(v)$ fails its mathematical constraint condition, the descriptor interrupts the assignment pipeline immediately, raising a TypeError or ValueError before any state modification occurs:

$$ \textbf{if } v \notin \text{Domain}(f_k) \implies \text{Raise Exception}(v) \quad \text{and abort } \text{\_\_set\_\_} $$

Furthermore, descriptors enable **Lazy Loading**. For instance, an ORM foreign key field (e.g., user.profile) can defer querying the database until the attribute is read for the first time. The descriptor checks if a cached value exists in instance.__dict__. If missing, it queries the database, caches the result in instance.__dict__['_profile'], and returns it:

$$ \text{\_\_get\_\_}(instance) = \begin{cases} \text{instance.\_\_dict\_\_}['_profile'] & \text{if cached} \\ \text{FetchFromDatabase}() \quad (\text{Cache \& Return}) & \text{if missing} \end{cases} $$

This guarantees zero database hits during object instantiation, loading heavy relations only on access. Below is a comparison table of attribute validation patterns:

Validation Pattern Code Reusability Class Definition Cleanliness Lazy Loading Compatibility
Manual Setter Functions Low (duplicates `set_age(val)` in every class) Poor (clutters class with getter/setter methods) Requires explicit caching logic in each method
Python Decorator (`@property`) Medium (requires decorating every property individually) Medium (clean dot notation for caller) Good (encapsulates getter checks)
Validation Descriptors Maximum (one `Typed` class validates 100 attributes) Optimal (declarative class field definitions) Optimal (manages caching transparently)

Pitfall — Mutating cached values returned by descriptors: If a lazy-loading descriptor caches a mutable object (like a list or dictionary) in `instance.__dict__` and returns it directly, external code can mutate the collection without triggering the descriptor's `__set__` validation. Return copies or immutable proxies when returning cached collections.


6. Automatic Name Binding: The `__set_name__` Hook

6.1 Python 3.6+ Attribute Naming

Before Python 3.6, descriptor instances did not know what attribute name they were assigned to in the parent class. Developers had to pass the variable name manually to the descriptor constructor: age = Typed(int, name='age').

Python 3.6 introduced the __set_name__(self, owner, name) hook. When a class is instantiated by the metaclass, Python automatically calls __set_name__ on all descriptors, passing the class owner and the attribute name automatically.


7. Advanced: CPython Attribute Resolution Sequence

7.1 The 5-Step CPython Algorithm

When CPython executes PyObject_GenericGetAttr to resolve obj.name, it executes a strict 5-step algorithm:

$$ \text{Lookup}(obj, name) = \text{Step}_1 \to \text{Step}_2 \to \text{Step}_3 \to \text{Step}_4 \to \text{Step}_5 $$

7.2 CPython `Objects/object.c` C-Level Pointer Traversal

To understand the exact mechanics of attribute resolution, we inspect CPython's core implementation in Objects/object.c. When Python evaluates obj.attr, the interpreter calls the C API function PyObject_GenericGetAttr(PyObject *obj, PyObject *name). Let $T$ represent the type object of instance obj ($T = \text{Py\_TYPE}(obj)$). The function queries $T$'s Method Resolution Order (MRO) tuples using the internal helper _PyType_Lookup(T, name):

$$ \text{descr} = \text{\_PyType\_Lookup}\left(\text{Py\_TYPE}(obj), \, name\right) $$

This C function returns a borrowed reference to a class-level object if present. Next, CPython inspects the C struct function pointers on descr->ob_type->tp_descr_get and descr->ob_type->tp_descr_set to classify the descriptor:

$$ \text{IsDataDescriptor}(\text{descr}) = \left( \text{descr} \neq \text{NULL} \right) \land \left( \text{descr->ob\_type->tp\_descr\_set} \neq \text{NULL} \right) $$

**Case 1: Data Descriptor.** If $\text{IsDataDescriptor}(\text{descr})$ evaluates to true, CPython immediately invokes the C function pointer descr->ob_type->tp_descr_get(descr, obj, (PyObject*)T). The instance dictionary is bypassed entirely.

**Case 2: Instance Dictionary.** If the attribute is not a Data Descriptor (or not present in $T$), CPython queries the instance's internal dictionary struct pointer obj->ob_size / tp_dictoffset. If key $name$ exists in obj.__dict__, CPython returns the stored pointer directly:

$$ \text{val} = \text{PyDict\_GetItemWithError}\left(\text{obj->dict}, \, name\right) $$

**Case 3: Non-Data Descriptor or Class Attribute.** If the key is missing from obj.__dict__, CPython falls back to the previously found class-level descr. If descr->ob_type->tp_descr_get is defined, CPython invokes it. If not, it returns descr as a raw class attribute.

In CPython 3.11 and newer, the Adaptive Specializing Interpreter optimizes this multi-step lookup for monomorphic call sites. When an attribute access instruction (like LOAD_ATTR) executes repeatedly on instances of the same class type, CPython replaces the generic LOAD_ATTR opcode with specialized bytecodes such as LOAD_ATTR_INSTANCE_VALUE or LOAD_ATTR_PROPERTY. These specialized opcodes embed direct memory offsets into the bytecode stream, executing attribute reads in a fraction of the time by bypassing full MRO dictionary traversals whenever type guards pass.

Below is a comparison table of CPython C-level function pointer flags during lookup:

Descriptor Class CPython C-Struct Pointer Status Precedence over Instance `__dict__` Typical Built-in Example
Data Descriptor Both `tp_descr_get` and `tp_descr_set` are non-NULL Highest (overrides instance dictionary) `@property`, `__slots__` member descriptors
Non-Data Descriptor Only `tp_descr_get` is non-NULL (`tp_descr_set` is NULL) Lower (shadowed by instance dictionary) Functions, `@classmethod`, `@staticmethod`
Standard Class Attribute Both `tp_descr_get` and `tp_descr_set` are NULL Lowest (shadowed by instance dictionary) Class-level constants (e.g. `DEFAULT_TIMEOUT = 30`)

Pitfall — Overriding __getattribute__ incorrectly: If you override __getattribute__ on a custom class and write return self.attr inside it, you create an infinite recursion loop that crashes Python with a RecursionError. Always delegate to the parent using super().__getattribute__(name).

  1. **Check Class MRO for Data Descriptor**: Query the class and its bases (MRO). If an attribute exists and is a Data Descriptor (defines __get__ AND __set__/__delete__), execute its __get__ method and return.
  2. **Check Instance `__dict__`**: Query the object's instance dictionary obj.__dict__. If the key exists, return obj.__dict__[name].
  3. **Check Class MRO for Non-Data Descriptor or Value**: Query the class MRO. If a Non-Data Descriptor (defines only __get__) exists, execute its __get__. If a standard class variable exists, return it.
  4. **Check Class `__getattr__`**: If defined on the class, invoke __getattr__(name).
  5. **Raise AttributeError**: If all previous steps fail, raise an AttributeError.

8. Advanced: Comparison of Attribute Interception Strategies

8.1 Interception Strategy Matrix

The table below compares the performance, complexity, and scope of different attribute interception methods in Python:

Interception Strategy Execution Scope Reusability Across Classes CPython Access Overhead
Instance `__dict__` Lookup Single instance None (raw data storage) Fastest ($O(1)$ C-level hash table read)
Property (`@property`) Single class attribute Low (requires explicit property decorator per attribute) Medium (invokes Python function call)
Custom Descriptors Any class attribute High (reusable validation classes) Medium (invokes descriptor __get__)
`__getattr__` / `__getattribute__` All attributes on instance High (intercepts all accesses) Slowest (intercepts every dot lookup on the object)

Pitfall — Memory leaks using descriptor instances as storage: Storing instance values inside a dictionary on the descriptor instance itself (e.g. self.values[instance] = val) creates strong references to instances. This prevents Python's garbage collector from freeing instances, causing severe memory leaks. Use private instance attributes (e.g. setattr(instance, self.private_name, val)) instead.


9. Complete Custom Descriptor Suite Implementation in Python

9.1 The ORM Field Validator Code

The following Python code demonstrates a complete suite of validation descriptors using __set_name__:

class Validator:
    def __set_name__(self, owner, name):
        self.private_name = f"_{name}"
 
    def __get__(self, instance, owner=None):
        if instance is None:
            return self # Accessed on class level (User.age)
        return getattr(instance, self.private_name, None)
 
    def __set__(self, instance, value):
        self.validate(value)
        setattr(instance, self.private_name, value)
 
    def validate(self, value):
        pass
 
class Typed(Validator):
    def __init__(self, expected_type):
        self.expected_type = expected_type
 
    def validate(self, value):
        if not isinstance(value, self.expected_type):
            raise TypeError(f"Expected {self.expected_type}, got {type(value)}")
 
class BoundedNumber(Typed):
    def __init__(self, expected_type, min_val=None, max_val=None):
        super().__init__(expected_type)
        self.min_val = min_val
        self.max_val = max_val
 
    def validate(self, value):
        super().validate(value)
        if self.min_val is not None and value < self.min_val:
            raise ValueError(f"Value must be >= {self.min_val}")
        if self.max_val is not None and value > self.max_val:
            raise ValueError(f"Value must be <= {self.max_val}")
 
class User:
    name = Typed(str)
    age = BoundedNumber(int, min_val=0, max_val=150)
 
    def __init__(self, name, age):
        self.name = name # Triggers Typed.__set__
        self.age = age # Triggers BoundedNumber.__set__

10. Interactive: Attribute Lookup Resolution Simulator

Click "Step Resolution" to trace how Python resolves user.age through CPython's 5-step lookup algorithm:

Step: Ready
Query: getattr(user, "age")
Log output displays here...
Step 1: Check Class MRO for Data Descriptor
Step 2: Check Instance __dict__
Step 3: Check Class MRO for Non-Data Descriptor / Value
Step 4: Invoke __getattr__

Execution Latency: Attribute Access Overhead

The chart below compares the time taken (in milliseconds) for 10 million attribute accesses using direct instance attributes vs properties vs custom descriptors:


12. Frequently Asked Questions

Q1: Why must descriptors be defined at the class level?

Because Python queries the class (via `type(obj)`) to inspect descriptors during attribute resolution. If defined on an instance, Python treats it as a standard dictionary value and ignores the descriptor protocol.

Q2: What is the difference between a Data Descriptor and a Non-Data Descriptor?

Data descriptors define both `__get__` and `__set__` (or `__delete__`), taking precedence over instance `__dict__`. Non-data descriptors define only `__get__` and are shadowed by instance `__dict__` keys.

Q3: How does @property work in Python?

`property` is a built-in Data Descriptor. It wraps getter, setter, and deleter functions, executing them during attribute access.

Q4: What happens if `__get__` is accessed directly on the class (e.g. `User.age`)?

When accessed on the class, the `instance` argument in `__get__(self, instance, owner)` is `None`. Descriptors usually return `self` in this scenario.

Q5: How does `__set_name__` simplify descriptor creation?

It automatically passes the attribute name to the descriptor when the class is constructed, allowing descriptors to automatically configure private storage names without explicit constructor arguments.

Q6: Can descriptors be combined with `__slots__`?

Yes. `__slots__` uses descriptors internally to store object attributes in fixed C arrays instead of `__dict__` hashes.

Q7: Do functions in Python use descriptors?

Yes. All Python functions implement `__get__` (non-data descriptors). When accessed via an instance, `function.__get__(instance)` returns a bound method with `self` prepended to arguments.

Q8: How do I verify my custom descriptors have no memory leaks?

Verify: (1) check that values are stored on instances (not inside descriptor dictionaries), (2) verify `__set_name__` correctly configures private names, (3) verify class-level access returns `self`, and (4) verify that garbage collection reclaims instances properly.

Post a Comment

Previous Post Next Post