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.

To see how these methods work together, the official Python documentation provides an excellent primer.
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 specificProductobject). This is how the descriptor can access instance-specific state.owner: The class that owns the descriptor (e.g., theProductclass).
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 bindselfand return a method.
The lookup order is roughly as follows when you access obj.x:
- Python checks if
xis a data descriptor ontype(obj). If so, the descriptor's__get__is called. This wins. - If not, it checks
obj.__dict__forx. If found, that value is returned. - If not, it checks if
xis a non-data descriptor ontype(obj). If so, its__get__is called. - If not, it looks for
xon the class itself and its parent classes. - Finally, it raises an
AttributeError.

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:
- The
__init__method should accept the expected type (e.g.,str,int). - The
__set_name__method should capture the public and private names for the attribute. - The
__set__method should check if the assignedvalueis an instance of theexpected_type. If not, it should raise aTypeError. - 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. @propertyis 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.