# Descriptors in Python — The Ultimate Guide

## Introduction

In Python, **everything is an object**, and attribute access is a fundamental operation. But what if you wanted to control **how** an attribute behaves when accessed, modified, or deleted? That’s where **descriptors** come in. They’re a powerful, often overlooked feature that let you **manage attribute access dynamically** within a class.

This guide provides a **complete overview of descriptors**, including **how they work, when to use them, and best practices for clean, reusable implementations.**

## What Are Descriptors?

A **descriptor** is a Python object that implements one or more of the following special methods:

* `__get__(self, instance, owner)`: Called when the attribute is accessed.
    
* `__set__(self, instance, value)`: Called when the attribute is assigned a new value.
    
* `__delete__(self, instance)`: Called when the attribute is deleted with `del`.
    

### How Attribute Lookup Works

When an attribute is accessed in Python, the interpreter follows **a specific lookup order**:

1. **Instance dictionary (**`__dict__`) → If the attribute exists in the instance, Python retrieves it directly.
    
2. **Class attributes** → If not found in the instance, Python checks the class definition for the attribute.
    
3. **Descriptors** → If the attribute is a descriptor, Python invokes its special methods (`__get__`, `__set__`, `__delete__`).
    
4. **Inheritance chain** → If still unresolved, Python searches in parent classes.
    
5. **Fallback** → If the attribute is missing everywhere, Python raises an `AttributeError`.
    

Thus, descriptors **override** instance attributes under certain conditions, making them extremely powerful for **fine-grained control** over attribute behavior.

---

## Why Use Descriptors?

Descriptors provide **precise control** over attribute access, enabling:

* **Validation and type-checking** → Ensure values meet specific criteria.
    
* **Computed properties** → Define attributes that calculate values dynamically.
    
* **Resource management** → Handle opening/closing connections, caching values.
    
* **Access control** → Implement authentication and logging for sensitive attributes.
    
* **Lazy evaluation** → Defer expensive computations until needed.
    

### Built-in Python Features Using Descriptors

Several core Python features use descriptors internally:

* `property()` → Implements computed attributes.
    
* `staticmethod()` & `classmethod()` → Modify method behavior at the class level.
    
* `functools.cached_property` → Implements efficient caching.
    

## The Descriptor Protocol Methods

### `__get__(self, instance, owner)` → Handles attribute retrieval

Called when the attribute is accessed on an instance.

```python
class MyDescriptor:
    def __get__(self, instance, owner):
        print("Getting value")
        return instance._value  # Return stored value

class MyClass:
    value = MyDescriptor()

obj = MyClass()
obj.value  # Calls __get__()
```

---

### `__set__(self, instance, value)` → Handles attribute assignment

Called when an attribute is set. Often used for **validation** or **transformations**.

```python
class MyDescriptor:
    def __set__(self, instance, value):
        if value < 0:
            raise ValueError("Negative value not allowed")
        print("Setting value")
        instance._value = value  # Store validated value

class MyClass:
    value = MyDescriptor()

obj = MyClass()
obj.value = 10  # Calls __set__()
```

---

### `__delete__(self, instance)` → Handles attribute deletion

Called when an attribute is deleted using `del`.

```python
class MyDescriptor:
    def __delete__(self, instance):
        print("Deleting value")
        del instance._value  # Remove stored value

class MyClass:
    value = MyDescriptor()

obj = MyClass()
del obj.value  # Calls __delete__()
```

---

## Data vs Non-Data Descriptors

* **Data descriptors** → Implement **both** `__get__` **and** (`__set__` or `__delete__`).
    
* **Non-data descriptors** → Implement **only** `__get__`.
    

Data descriptors have **higher precedence** than instance attributes, while non-data descriptors do **not**.

```python
class NonData:
    def __get__(self, instance, owner):
        return "Non-data descriptor"

class Data:
    def __get__(self, instance, owner):
        return "Data descriptor"
    
    def __set__(self, instance, value):
        pass

class Example:
    nd = NonData()
    d = Data()

e = Example()
e.nd = "Instance attribute"
print(e.nd)  # 'Instance attribute' because NonData is non-data descriptor
print(e.d)   # 'Data descriptor' because Data takes precedence
```

## How Python’s Built-ins Use Descriptors

### `property()` example

The `property()` function **creates a descriptor internally**, which manages attribute access.

```python
class Circle:
    def __init__(self, radius):
        self._radius = radius

    def get_radius(self):
        return self._radius

    def set_radius(self, value):
        if value < 0:
            raise ValueError("Negative radius not allowed")
        self._radius = value

    radius = property(get_radius, set_radius)

c = Circle(5)
print(c.radius)  # Calls get_radius()
c.radius = 10    # Calls set_radius()
```

## Real-World Use Cases for Descriptors

### **1\. Validation** → Ensure values meet requirements

```python
class PositiveNumber:
    def __set__(self, instance, value):
        if value < 0:
            raise ValueError("Only positive values allowed")
        instance._value = value

class Account:
    balance = PositiveNumber()

a = Account()
a.balance = -100  # Raises ValueError
```

### **2\. Caching** → Store expensive computations

```python
class CachedProperty:
    def __get__(self, instance, owner):
        if "_cached_value" not in instance.__dict__:
            instance._cached_value = instance.expensive_computation()
        return instance._cached_value

class MyClass:
    cache = CachedProperty()

    def expensive_computation(self):
        print("Running expensive computation...")
        return 42

obj = MyClass()
print(obj.cache)  # Runs computation once
print(obj.cache)  # Returns cached value
```

### **3\. Logging and Debugging** → Track attribute changes

```python
class LoggingDescriptor:
    def __set__(self, instance, value):
        print(f"Setting {instance.__class__.__name__}.value to {value}")
        instance._value = value

class MyClass:
    value = LoggingDescriptor()

obj = MyClass()
obj.value = 100  # Logs assignment
```

## Best Practices

* Use descriptors when **multiple classes** need **shared attribute logic**.
    
* Keep descriptor classes **modular and reusable**.
    
* Use `property()` for **simpler cases**.
    
* Avoid excessive complexity—**only use descriptors when truly necessary**.
    

## Limitations

* **Increased complexity** → Can make code harder to debug.
    
* **Performance impact** → Descriptors add **a level of indirection** in attribute lookup.
    
* **Not always necessary** → `property()` is often sufficient.
    

## Conclusion

Descriptors are **one of Python’s most powerful mechanisms** for controlling attribute behavior. They are used internally in Python **for built-in features** like `property()`, and they provide **fine-grained control** over attribute access.

By **mastering descriptors**, you gain **a deeper understanding of Python internals**—preparing you to build **robust frameworks, APIs, and high-performance applications**.
