Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 16

Custom Descriptors: Implementing `__get__`, `__set__`, and `__delete__`

Recommended resources

Introduction

In the last lesson, you mastered the @property decorator, a Pythonic tool for creating managed attributes that neatly replaces Java-style get_x() and set_x() methods. You saw how properties allow you to add logic—like validation or computation—to an attribute while maintaining a clean, direct-access API.

However, @property has its limits. Consider a class where you need to apply the same validation logic to multiple attributes. For example:

class Product:
    def __init__(self, price, weight, stock):
        self.price = price
        self.weight = weight
        self.stock = stock

    @property
    def price(self):
        return self._price

    @price.setter
    def price(self, value):
        if not isinstance(value, (int, float)) or value < 0:
            raise ValueError("Price must be a non-negative number.")
        self._price = value

    @property
    def weight(self):
        return self._weight

    @weight.setter
    def weight(self, value):
        if not isinstance(value, (int, float)) or value < 0:
            raise ValueError("Weight must be a non-negative number.")
        self._weight = value
    



    # ... and another property for 'stock' ...

This code is not "DRY" (Don't Repeat Yourself). The validation logic is copied for each attribute.

This lesson dives into the powerful mechanism that underlies properties themselves: descriptors. You will learn to implement a custom descriptor class, enabling you to define reusable attribute logic that can be applied across any number of attributes, cleaning up your code and making it more robust and maintainable.


What is a Descriptor? The Protocol

At its core, a descriptor is simply any object that implements one or more of the following special "dunder" methods, collectively known as the descriptor protocol:

  • __get__(self, instance, owner): Controls what happens when an attribute's value is retrieved (obj.attr).
  • __set__(self, instance, value): Controls what happens when a value is assigned to an attribute (obj.attr = value).
  • __delete__(self, instance): Controls what happens when an attribute is deleted (del obj.attr).

When you place a descriptor object as a class variable, Python's attribute access mechanism automatically invokes these methods.

Python Descriptor Lifecycle Demonstration
This screenshot shows a descriptor's `__get__`, `__set__`, and `__delete__` methods being called automatically when the corresponding operations (access, assignment, deletion) are performed on an attribute in a class instance.

To see how these methods work together, the official Python documentation provides an excellent primer.

Descriptor How-to Guide

Read this guide from the Python documentation. It introduces the descriptor protocol by starting with a simple __get__ example and progressively adds __set__ for managed attributes and __set_name__ for customization.

Read the sections titled "Primer", "Simple example", "Dynamic lookups", "Managed attributes", and "Customized names". Focus on how each dunder method is introduced to add new functionality.

As you read, pay attention to the arguments:

  • self: The descriptor instance itself (e.g., our validator object).
  • instance: The instance of the class where the descriptor is being used (e.g., a specific Product object). This is how the descriptor can access instance-specific state.
  • owner: The class that owns the descriptor (e.g., the Product class).

Building a Reusable Validation Descriptor

Now, let's solve the code duplication problem from the introduction by building a reusable PositiveNumber descriptor. The @property decorator is itself a descriptor, but it's designed for single-use logic. By writing our own descriptor class, we create a reusable component.

The following video provides a clear, step-by-step walkthrough of this exact process.

You Use This Python Feature Everyday - But Never Learned It...

This video from Tech With Tim will guide you through the process of moving from individual properties to a reusable descriptor for validation. It effectively demonstrates the 'why' and 'how' of custom descriptors.

Watch from 10:43 to 15:50. This segment covers: The Problem (10:43 - 12:02): Introduces descriptors as a way to avoid rewriting the same property logic. Implementation (12:02 - 14:46): Details the implementation of a PositiveNumber descriptor, explaining __set_name__, __get__, and __set__. Usage (14:46 - 15:50): Shows how the finished descriptor is used in a class and tests its validation logic.

Let's break down the final code from the video. This is the core pattern for a modern, reusable descriptor.




# The Pythonic, reusable way using a custom descriptor
class PositiveNumber:
    def __set_name__(self, owner, name):



        # Called when the descriptor is assigned to the class.
        # We store the public attribute name (e.g., 'price').
        self.public_name = name



        # And create a "private" name for storing the value
        # in the instance's __dict__ (e.g., '_price').
        self.private_name = '_' + name

    def __get__(self, instance, owner):



        # `instance` is the object being accessed (e.g., a specific Product).
        # If accessing from the class itself (Product.price), instance is None.
        if instance is None:
            return self



        # Retrieve the value from the instance's __dict__ using the private name.
        return getattr(instance, self.private_name, 0)

    def __set__(self, instance, value):



        # Perform validation.
        if value < 0:
            raise ValueError(f'{self.public_name} must be positive.')



        # If valid, store the value in the instance's __dict__ using the private name.
        setattr(instance, self.private_name, value)

class Product:



    # Assign descriptor instances to class attributes.
    # Python automatically calls __set_name__ for each one.
    price = PositiveNumber()
    quantity = PositiveNumber()
    weight = PositiveNumber()

    def __init__(self, price, quantity, weight):



        # These assignments trigger the __set__ method of the descriptors.
        self.price = price
        self.quantity = quantity
        self.weight = weight




# --- Usage ---
p = Product(10.0, 5, 0.5)
print(f"Price: {p.price}, Quantity: {p.quantity}")  # Triggers __get__




# p.price = -1  # This would correctly raise a ValueError.

The __set_name__ method (introduced in Python 3.6) is key here. It allows the descriptor to automatically know which attribute name it was assigned to ('price', 'quantity', etc.), eliminating the need to pass the name manually.


Data vs. Non-Data Descriptors: A Look Under the Hood

There's a crucial detail in how Python's attribute lookup works: the distinction between data descriptors and non-data descriptors.

  • A data descriptor is a descriptor that implements __set__ or __delete__. These are for managing data and have the highest lookup priority.
  • A non-data descriptor only implements __get__. Python functions are a primary example; when accessed on an instance, their __get__ method is called to bind self and return a method.

The lookup order is roughly as follows when you access obj.x:

  1. Python checks if x is a data descriptor on type(obj). If so, the descriptor's __get__ is called. This wins.
  2. If not, it checks obj.__dict__ for x. If found, that value is returned.
  3. If not, it checks if x is a non-data descriptor on type(obj). If so, its __get__ is called.
  4. If not, it looks for x on the class itself and its parent classes.
  5. Finally, it raises an AttributeError.
Python Descriptor Lookup Flowchart
This flowchart illustrates Python's attribute lookup process. Note how data descriptors (those with `__set__` or `__delete__`) are checked first, giving them higher priority than attributes stored in the instance's `__dict__`.

Our PositiveNumber validator is a data descriptor because it has a __set__ method. This high priority is essential; it ensures that our __set__ and __get__ methods are always called, even if an attribute with the same name exists in the instance's __dict__. This prevents the validation logic from being bypassed.


Exercise: Create a Type-Validation Descriptor

Now it's your turn to build a descriptor. Your task is to create a Typed descriptor that enforces a specific type for an attribute. This is another extremely practical use case for descriptors.

Complete the Typed class below.

Goals:

  1. The __init__ method should accept the expected type (e.g., str, int).
  2. The __set_name__ method should capture the public and private names for the attribute.
  3. The __set__ method should check if the assigned value is an instance of the expected_type. If not, it should raise a TypeError.
  4. The __get__ method should retrieve the value.



# initial_code.py
class Typed:
    def __init__(self, expected_type):
        self.expected_type = expected_type

    def __set_name__(self, owner, name):
        self.public_name = name
        self.private_name = '_' + name

    def __get__(self, instance, owner):



        # TODO: Implement the getter
        # It should retrieve the value from the instance's __dict__
        # using the private_name.
        pass

    def __set__(self, instance, value):



        # TODO: Implement the setter
        # It should first check if `value` is an instance of `self.expected_type`.
        # If not, raise a TypeError with a helpful message.
        # If it is the correct type, store it in the instance's __dict__.
        pass




# --- Class using the descriptor ---
class Person:
    name = Typed(str)
    age = Typed(int)

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




# --- Tests ---
# Valid creation
p = Person("Alice", 30)
assert p.name == "Alice"
assert p.age == 30




# Test invalid name type
try:
    p.name = 123
except TypeError as e:
    print(f"Caught expected error: {e}")




# Test invalid age type
try:
    p.age = "thirty"
except TypeError as e:
    print(f"Caught expected error: {e}")




# Test assignment after creation
p.name = "Bob"
assert p.name == "Bob"

print("\nAll checks passed (if you see the error messages)!")
Click to see the solution



# solution_code.py
class Typed:
    def __init__(self, expected_type):
        self.expected_type = expected_type

    def __set_name__(self, owner, name):
        self.public_name = name
        self.private_name = '_' + name

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

    def __set__(self, instance, value):
        if not isinstance(value, self.expected_type):
            raise TypeError(f"'{self.public_name}' must be of type {self.expected_type.__name__}.")
        setattr(instance, self.private_name, value)




# --- Class using the descriptor ---
class Person:
    name = Typed(str)
    age = Typed(int)

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




# --- Tests ---
# Valid creation
p = Person("Alice", 30)
assert p.name == "Alice"
assert p.age == 30




# Test invalid name type
try:
    p.name = 123
except TypeError as e:
    print(f"Caught expected error: {e}")




# Test invalid age type
try:
    p.age = "thirty"
except TypeError as e:
    print(f"Caught expected error: {e}")




# Test assignment after creation
p.name = "Bob"
assert p.name == "Bob"

print("\nAll checks passed (if you see the error messages)!")

Conclusion

You have now moved beyond @property to the fundamental mechanism that powers it. By learning to write your own descriptor classes, you've unlocked a powerful pattern for creating reusable, robust, and clean attribute-management logic.

Key Takeaways:

  • Descriptors control attribute access: Any object with __get__, __set__, or __delete__ is a descriptor.
  • @property is for one-off logic: It's a convenient, built-in descriptor for managing a single attribute.
  • Custom descriptors are for reusable logic: When you need to apply the same validation, type-checking, or logging to multiple attributes, write a custom descriptor class to avoid repeating yourself.
  • __set_name__ is your friend: It simplifies descriptor implementation by automatically providing the attribute's name.
  • Data descriptors have priority: A descriptor with __set__ or __delete__ will always override a value stored in an instance's dictionary, making them ideal for validation.

We have now concluded our deep dive into Python's data model and how it allows your custom objects to feel native. In the next module, we will shift gears from object design to data processing, starting with the foundational idioms for writing clean, efficient, and Pythonic loops.