Python Descriptors and Properties: The Complete Guide
Understand Python's descriptor protocol, how properties work internally, and how to build reusable validation descriptors.
What you'll learn
- ✓How the descriptor protocol powers attribute access
- ✓Why property is a descriptor under the hood
- ✓Building reusable data descriptors for validation
- ✓The difference between data and non-data descriptors
Prerequisites
- •Python OOP fundamentals
- •Understanding of dunder methods
- •Familiarity with class attributes
What Is a Descriptor?
A descriptor is any object that defines __get__, __set__, or __delete__. When such an object is assigned as a class attribute, Python intercepts attribute access on instances and calls the descriptor methods instead of performing normal attribute lookup.
Descriptors are the mechanism behind property, classmethod, staticmethod, and even regular method binding. They are foundational to how Python works.
The Descriptor Protocol
The protocol consists of three optional methods:
class Descriptor:
def __get__(self, obj, objtype=None):
# Called when the attribute is accessed
pass
def __set__(self, obj, value):
# Called when the attribute is assigned
pass
def __delete__(self, obj):
# Called when the attribute is deleted
pass
When you access instance.attr, Python checks if attr on the class is a descriptor. If it has __get__, Python calls Descriptor.__get__(instance, type(instance)) instead of returning the descriptor object itself.
Data vs Non-Data Descriptors
This distinction is critical for understanding attribute lookup order:
- Data descriptor: Implements
__get__AND__set__(or__delete__). Takes priority over instance__dict__. - Non-data descriptor: Implements only
__get__. Instance__dict__takes priority.
class DataDescriptor:
"""Has both __get__ and __set__ -- takes priority over instance dict."""
def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj.__dict__.get('_value', 'default')
def __set__(self, obj, value):
obj.__dict__['_value'] = value
class NonDataDescriptor:
"""Has only __get__ -- instance dict takes priority."""
def __get__(self, obj, objtype=None):
if obj is None:
return self
return "from descriptor"
class MyClass:
data = DataDescriptor()
non_data = NonDataDescriptor()
obj = MyClass()
obj.__dict__['data'] = "from dict"
obj.__dict__['non_data'] = "from dict"
print(obj.data) # "default" -- data descriptor wins over __dict__
print(obj.non_data) # "from dict" -- __dict__ wins over non-data descriptor
How property Actually Works
The built-in property is just a data descriptor implemented in C. Here is a pure Python equivalent:
class Property:
"""Pure Python implementation of the property descriptor."""
def __init__(self, fget=None, fset=None, fdel=None, doc=None):
self.fget = fget
self.fset = fset
self.fdel = fdel
self.__doc__ = doc or (fget.__doc__ if fget else None)
def __get__(self, obj, objtype=None):
if obj is None:
return self
if self.fget is None:
raise AttributeError("unreadable attribute")
return self.fget(obj)
def __set__(self, obj, value):
if self.fset is None:
raise AttributeError("can't set attribute")
self.fset(obj, value)
def __delete__(self, obj):
if self.fdel is None:
raise AttributeError("can't delete attribute")
self.fdel(obj)
def getter(self, fget):
return type(self)(fget, self.fset, self.fdel, self.__doc__)
def setter(self, fset):
return type(self)(self.fget, fset, self.fdel, self.__doc__)
def deleter(self, fdel):
return type(self)(self.fget, self.fset, fdel, self.__doc__)
When you write @property, you create a Property instance with fget set. When you add @name.setter, you create a new Property with fset also set.
Building Reusable Validation Descriptors
The real power of descriptors is reusability. Instead of writing @property with validation in every class, you write the descriptor once:
class Validated:
"""Base class for validated descriptors."""
def __set_name__(self, owner, name):
self.public_name = name
self.private_name = f'_{name}'
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.private_name, None)
def __set__(self, obj, value):
self.validate(value)
setattr(obj, self.private_name, value)
def validate(self, value):
raise NotImplementedError
class PositiveNumber(Validated):
def validate(self, value):
if not isinstance(value, (int, float)):
raise TypeError(f"{self.public_name} must be a number")
if value <= 0:
raise ValueError(f"{self.public_name} must be positive")
class NonEmptyString(Validated):
def __init__(self, max_length=None):
self.max_length = max_length
def validate(self, value):
if not isinstance(value, str):
raise TypeError(f"{self.public_name} must be a string")
if not value.strip():
raise ValueError(f"{self.public_name} cannot be empty")
if self.max_length and len(value) > self.max_length:
raise ValueError(
f"{self.public_name} cannot exceed {self.max_length} chars"
)
class Product:
name = NonEmptyString(max_length=100)
price = PositiveNumber()
quantity = PositiveNumber()
def __init__(self, name, price, quantity):
self.name = name
self.price = price
self.quantity = quantity
p = Product("Widget", 9.99, 100) # Works
# Product("", 9.99, 100) # ValueError: name cannot be empty
# Product("Widget", -1, 100) # ValueError: price must be positive
Notice __set_name__. Introduced in Python 3.6, this method is called automatically when a descriptor is assigned to a class attribute. It receives the attribute name, eliminating the need to pass it manually.
Descriptors for Lazy Computation
A non-data descriptor can implement lazy attribute computation that caches itself in the instance dict:
class LazyProperty:
"""Compute a value once, then cache it on the instance."""
def __init__(self, func):
self.func = func
self.__doc__ = func.__doc__
def __set_name__(self, owner, name):
self.attr_name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
value = self.func(obj)
# Store in instance __dict__ so this descriptor
# is never called again for this instance
setattr(obj, self.attr_name, value)
return value
class DataProcessor:
def __init__(self, raw_data):
self.raw_data = raw_data
@LazyProperty
def parsed(self):
"""Expensive parsing, only done once."""
print("Parsing data...")
return [int(x) for x in self.raw_data.split(",")]
@LazyProperty
def summary(self):
"""Compute summary statistics."""
print("Computing summary...")
data = self.parsed
return {"min": min(data), "max": max(data), "mean": sum(data) / len(data)}
dp = DataProcessor("1,2,3,4,5")
print(dp.parsed) # Prints "Parsing data..." then [1, 2, 3, 4, 5]
print(dp.parsed) # No parsing message -- cached in instance __dict__
This works because LazyProperty is a non-data descriptor (no __set__), so once the value is stored in the instance __dict__, the instance attribute takes priority.
The set_name Hook
This small but important method makes descriptors much more ergonomic:
class TypeChecked:
def __init__(self, expected_type):
self.expected_type = expected_type
def __set_name__(self, owner, name):
self.name = name
self.private = f"_{name}"
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.private, None)
def __set__(self, obj, value):
if not isinstance(value, self.expected_type):
raise TypeError(
f"{self.name} must be {self.expected_type.__name__}, "
f"got {type(value).__name__}"
)
setattr(obj, self.private, value)
class Config:
host = TypeChecked(str)
port = TypeChecked(int)
debug = TypeChecked(bool)
def __init__(self, host, port, debug=False):
self.host = host
self.port = port
self.debug = debug
Wrapping Up
Descriptors are the hidden machinery behind many Python features. Properties, methods, class methods, and static methods are all descriptors. By understanding the protocol and the data vs non-data distinction, you can build powerful, reusable attribute management tools that keep your classes clean and your validation logic centralized.
Related articles
- Python Python Descriptors: The Protocol Behind Properties
Learn how Python descriptors work under the hood, powering properties, class methods, and custom attribute access with __get__, __set__, and __delete__.
- Python Python ABCs vs Protocols: Choosing the Right Abstraction
Understand the difference between Abstract Base Classes and Protocols in Python. Learn when nominal typing beats structural typing and vice versa.
- Python Python Design Patterns: Strategy, Factory, and Observer
Learn how to implement the Strategy, Factory, and Observer design patterns in Python using idiomatic constructs like first-class functions and protocols.
- Python Advanced Python Dataclasses: Beyond the Basics
Go beyond basic dataclass usage with post-init processing, field factories, inheritance, frozen instances, and custom serialization.