Skip to main content
Advertisement

Descriptors

Descriptors are objects that implement one or more of __get__, __set__, or __delete__, and serve as Python's core mechanism for intercepting attribute access when used as class attributes. @property, classmethod, and staticmethod are all descriptors.


The Descriptor Protocol

class Descriptor:
"""The three methods of the descriptor protocol"""

def __get__(self, obj, objtype=None):
"""Attribute read: called on obj.attr or Class.attr"""
if obj is None:
# Direct access from the class (Class.attr)
return self
return getattr(obj, "_value", None)

def __set__(self, obj, value):
"""Attribute write: obj.attr = value"""
obj._value = value

def __delete__(self, obj):
"""Attribute delete: del obj.attr"""
try:
del obj._value
except AttributeError:
raise AttributeError("Cannot delete attribute.")


class MyClass:
attr = Descriptor() # Registered as a class attribute


obj = MyClass()
obj.attr = 42 # Calls __set__
print(obj.attr) # Calls __get__ → 42
print(MyClass.attr) # __get__ with obj=None → returns Descriptor instance
del obj.attr # Calls __delete__

Data Descriptors vs Non-Data Descriptors

# Data descriptor: implements __set__ or __delete__ → takes priority over __dict__
class DataDescriptor:
def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj.__dict__.get(f"__{id(self)}__")

def __set__(self, obj, value):
obj.__dict__[f"__{id(self)}__"] = value


# Non-data descriptor: implements only __get__ → loses to __dict__
class NonDataDescriptor:
def __get__(self, obj, objtype=None):
if obj is None:
return self
return "non-data descriptor value"


class Demo:
data = DataDescriptor()
non_data = NonDataDescriptor()


d = Demo()
d.data = "data descriptor"
d.__dict__["data"] = "instance __dict__" # data descriptor wins → ignored

d.__dict__["non_data"] = "instance __dict__" # __dict__ wins over non-data descriptor

print(d.data) # data descriptor (descriptor takes priority)
print(d.non_data) # instance __dict__ (__dict__ takes priority)

Type-Validating Descriptor

class TypedAttribute:
"""Data descriptor that performs type validation"""

def __init__(self, name: str, expected_type: type):
self.name = name
self.expected_type = expected_type
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):
if not isinstance(value, self.expected_type):
raise TypeError(
f"{self.name}: expected {self.expected_type.__name__}, "
f"got {type(value).__name__} ({value!r})"
)
setattr(obj, self.private_name, value)

def __delete__(self, obj):
try:
delattr(obj, self.private_name)
except AttributeError:
pass


class RangedFloat:
"""Range-validating descriptor"""

def __init__(self, name: str, min_val: float, max_val: float):
self.name = name
self.min_val = min_val
self.max_val = max_val
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):
if not isinstance(value, (int, float)):
raise TypeError(f"{self.name} must be a number.")
if not (self.min_val <= value <= self.max_val):
raise ValueError(
f"{self.name} must be in range {self.min_val}~{self.max_val}: {value}"
)
setattr(obj, self.private_name, float(value))

def __delete__(self, obj):
setattr(obj, self.private_name, None)


class Person:
name = TypedAttribute("name", str)
age = TypedAttribute("age", int)
height = RangedFloat("height", 0.0, 300.0)
weight = RangedFloat("weight", 0.0, 500.0)

def __init__(self, name: str, age: int, height: float, weight: float):
self.name = name
self.age = age
self.height = height
self.weight = weight

def __repr__(self) -> str:
return f"Person({self.name!r}, {self.age}, {self.height}cm, {self.weight}kg)"

@property
def bmi(self) -> float:
h_m = self.height / 100
return self.weight / (h_m ** 2) if h_m > 0 else 0


p = Person("Alice", 30, 165.0, 60.0)
print(p)
print(f"BMI: {p.bmi:.1f}")

# Type error
try:
p.age = "thirty"
except TypeError as e:
print(f"Type error: {e}")

# Range error
try:
p.height = 400.0
except ValueError as e:
print(f"Range error: {e}")

Using __set_name__ (Python 3.6+)

class Validated:
"""Descriptor that automatically obtains the attribute name via __set_name__"""

def __set_name__(self, owner, name: str):
"""Called automatically at class definition — gives access to attribute name"""
self.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):
value = self.validate(value)
setattr(obj, self.private_name, value)

def validate(self, value):
return value # Override in subclass


class PositiveInt(Validated):
def validate(self, value) -> int:
if not isinstance(value, int):
raise TypeError(f"{self.name}: expected int, got {type(value).__name__}")
if value <= 0:
raise ValueError(f"{self.name}: expected positive, got {value}")
return value


class NonEmptyStr(Validated):
def validate(self, value) -> str:
if not isinstance(value, str):
raise TypeError(f"{self.name}: expected str, got {type(value).__name__}")
stripped = value.strip()
if not stripped:
raise ValueError(f"{self.name}: empty string is not allowed.")
return stripped


class Product:
name = NonEmptyStr() # __set_name__ automatically passes "name"
price = PositiveInt() # __set_name__ automatically passes "price"
stock = PositiveInt() # __set_name__ automatically passes "stock"

def __init__(self, name: str, price: int, stock: int):
self.name = name
self.price = price
self.stock = stock

def __repr__(self) -> str:
return f"Product({self.name!r}, ${self.price}, {self.stock} in stock)"


p = Product("Python Book", 35, 100)
print(p)

try:
Product("", 35, 100)
except ValueError as e:
print(f"Error: {e}")

try:
Product("Book", -100, 50)
except ValueError as e:
print(f"Error: {e}")

Caching Descriptor

import functools


class cached_property:
"""Non-data descriptor that caches expensive-to-compute properties"""

def __init__(self, func):
self.func = func
self.attrname = None
self.__doc__ = func.__doc__

def __set_name__(self, owner, name: str):
self.attrname = name

def __get__(self, obj, objtype=None):
if obj is None:
return self
# Non-data descriptor: __dict__ takes priority → computed only once
val = self.func(obj)
obj.__dict__[self.attrname] = val # Store directly in instance __dict__
return val


class Circle:
def __init__(self, radius: float):
self.radius = radius

@cached_property
def area(self) -> float:
"""Assume this is computationally expensive"""
import math
print(" [Computing area...]")
return math.pi * self.radius ** 2

@cached_property
def perimeter(self) -> float:
print(" [Computing perimeter...]")
import math
return 2 * math.pi * self.radius


c = Circle(5.0)

print("First access:")
print(f" Area: {c.area:.2f}") # Computation runs

print("Second access:")
print(f" Area: {c.area:.2f}") # Returned from cache

print(f" Perimeter: {c.perimeter:.2f}") # Computation runs
print(f" Perimeter: {c.perimeter:.2f}") # Returned from cache

# functools.cached_property from the standard library works on the same principle
from functools import cached_property as std_cached_property

class Polygon:
def __init__(self, vertices: list[tuple[float, float]]):
self.vertices = vertices

@std_cached_property
def perimeter(self) -> float:
import math
total = 0.0
n = len(self.vertices)
for i in range(n):
x1, y1 = self.vertices[i]
x2, y2 = self.vertices[(i + 1) % n]
total += math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2)
return total

Understanding Descriptor Internals

# Attribute lookup order (MRO + descriptor priority)
# 1. Data descriptors (have __set__ or __delete__ in type(obj).__mro__)
# 2. Instance __dict__
# 3. Non-data descriptors (have only __get__ in type(obj).__mro__)

class Spy:
"""Descriptor that traces the attribute access flow"""

def __set_name__(self, owner, name: str):
self.name = name

def __get__(self, obj, objtype=None):
print(f" __get__({obj!r}, {objtype.__name__ if objtype else None})")
if obj is None:
return self
return obj.__dict__.get(f"_{self.name}")

def __set__(self, obj, value):
print(f" __set__({obj!r}, {value!r})")
obj.__dict__[f"_{self.name}"] = value

def __delete__(self, obj):
print(f" __delete__({obj!r})")
obj.__dict__.pop(f"_{self.name}", None)


class Container:
value = Spy()

def __repr__(self) -> str:
return f"Container()"


print("=== Write ===")
c = Container()
c.value = 100

print("\n=== Read ===")
print(c.value)

print("\n=== Read from class ===")
print(Container.value) # obj=None → returns Spy instance

print("\n=== Delete ===")
del c.value

Pro Tips

1. Descriptor vs Property

# @property: independent per class, not reusable
class Circle:
def __init__(self, radius: float):
self._radius = radius

@property
def radius(self) -> float:
return self._radius

@radius.setter
def radius(self, value: float) -> None:
if value <= 0:
raise ValueError("Radius must be positive.")
self._radius = value


# Descriptor: reusable across multiple classes
class PositiveFloat:
def __set_name__(self, owner, name: str):
self.name = name

def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj.__dict__.get(f"_{self.name}", 0.0)

def __set__(self, obj, value: float) -> None:
if not isinstance(value, (int, float)) or value <= 0:
raise ValueError(f"{self.name} must be positive: {value}")
obj.__dict__[f"_{self.name}"] = float(value)


class Rectangle:
width = PositiveFloat() # Reused
height = PositiveFloat() # Reused

def __init__(self, width: float, height: float):
self.width = width
self.height = height


class Cylinder:
radius = PositiveFloat() # Same descriptor reused
height = PositiveFloat()

def __init__(self, radius: float, height: float):
self.radius = radius
self.height = height

2. __slots__ and Descriptors

class SlottedDescriptor:
"""Descriptor for use with classes that have __slots__"""

def __set_name__(self, owner, name: str):
self.name = name
# Automatically add a storage slot to __slots__
slot_name = f"_{name}_slot"
if not hasattr(owner, "__slots__"):
raise TypeError("Only use with classes that have __slots__.")

def __get__(self, obj, objtype=None):
if obj is None:
return self
try:
return object.__getattribute__(obj, f"_{self.name}_slot")
except AttributeError:
return None

def __set__(self, obj, value):
object.__setattr__(obj, f"_{self.name}_slot", value)

Summary

MethodWhen CalledReturn Value
__get__(self, obj, type)Reading obj.attrAttribute value (returns descriptor itself if obj=None)
__set__(self, obj, value)obj.attr = valueNone
__delete__(self, obj)del obj.attrNone
__set_name__(self, owner, name)At class definitionNone

Priority Rules:

  1. Data descriptors (have __set__ or __delete__)
  2. Instance __dict__
  3. Non-data descriptors (have only __get__)

@property is a data descriptor, so it always takes priority over instance __dict__.

Advertisement