Skip to content

Descriptors

Control attribute access with descriptors - the power behind @property and more.

Descriptors are a powerful Python feature that control how attributes are accessed, set, and deleted. They’re the mechanism behind @property, @staticmethod, @classmethod, and many other Python features.

Without descriptors (manual validation):

without_descriptors.py
class Person:
def __init__(self, age: int):
self._age = None
self.set_age(age) # Must call setter
def get_age(self) -> int:
"""Get age"""
return self._age
def set_age(self, value: int):
"""Set age with validation"""
if value < 0:
raise ValueError("Age cannot be negative")
self._age = value
# Usage - verbose and error-prone
person = Person(25)
print(person.get_age()) # Must use getter
person.set_age(30) # Must use setter
person.set_age(-5) # Raises ValueError

With descriptors (clean and automatic):

with_descriptors.py
class PositiveInteger:
"""Descriptor for positive integers"""
def __init__(self, name: str):
self.name = name
def __get__(self, obj, objtype=None):
return obj.__dict__.get(self.name)
def __set__(self, obj, value: int):
if value < 0:
raise ValueError(f"{self.name} cannot be negative")
obj.__dict__[self.name] = value
class Person:
age = PositiveInteger('age') # Descriptor
def __init__(self, age: int):
self.age = age # Automatically uses descriptor!
# Usage - clean and automatic
person = Person(25)
print(person.age) # Automatically uses __get__
person.age = 30 # Automatically uses __set__
person.age = -5 # Raises ValueError automatically

A descriptor must implement at least one of these methods:

  • __get__(self, obj, objtype=None) - Called when attribute is accessed
  • __set__(self, obj, value) - Called when attribute is set
  • __delete__(self, obj) - Called when attribute is deleted
basic_descriptor.py
class MyDescriptor:
"""Basic descriptor example"""
def __init__(self, name: str):
self.name = name
def __get__(self, obj, objtype=None):
"""Called when: obj.attr or Class.attr"""
if obj is None:
# Accessed on class, not instance
return self
print(f"Getting {self.name}")
return obj.__dict__.get(self.name, None)
def __set__(self, obj, value):
"""Called when: obj.attr = value"""
print(f"Setting {self.name} to {value}")
obj.__dict__[self.name] = value
def __delete__(self, obj):
"""Called when: del obj.attr"""
print(f"Deleting {self.name}")
del obj.__dict__[self.name]
class MyClass:
x = MyDescriptor('x') # Descriptor instance
# Usage
obj = MyClass()
obj.x = 10 # Prints: Setting x to 10
print(obj.x) # Prints: Getting x, then 10
del obj.x # Prints: Deleting x

Data descriptors implement __set__ or __delete__:

data_descriptor.py
class ValidatedString:
"""Data descriptor that validates string length"""
def __init__(self, name: str, min_length: int = 0, max_length: int = 100):
self.name = name
self.min_length = min_length
self.max_length = max_length
def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj.__dict__.get(self.name, "")
def __set__(self, obj, value: str):
if not isinstance(value, str):
raise TypeError(f"{self.name} must be a string")
if len(value) < self.min_length:
raise ValueError(f"{self.name} must be at least {self.min_length} characters")
if len(value) > self.max_length:
raise ValueError(f"{self.name} must be at most {self.max_length} characters")
obj.__dict__[self.name] = value
class User:
username = ValidatedString('username', min_length=3, max_length=20)
email = ValidatedString('email', min_length=5, max_length=100)
def __init__(self, username: str, email: str):
self.username = username # Validation happens automatically!
self.email = email
# Usage
user = User("alice", "alice@example.com") # Works
user.username = "ab" # Raises ValueError (too short)
user.email = "a" # Raises ValueError (too short)

Non-data descriptors only implement __get__:

non_data_descriptor.py
class CachedProperty:
"""Non-data descriptor that caches computed values"""
def __init__(self, func):
self.func = func
self.name = func.__name__
def __get__(self, obj, objtype=None):
if obj is None:
return self
# Check cache
cache_attr = f"_{self.name}_cache"
if hasattr(obj, cache_attr):
return getattr(obj, cache_attr)
# Compute and cache
value = self.func(obj)
setattr(obj, cache_attr, value)
return value
class Circle:
def __init__(self, radius: float):
self.radius = radius
@CachedProperty
def area(self):
"""Compute area (cached after first access)"""
print("Computing area...") # Only prints once
return 3.14159 * self.radius ** 2
# Usage
circle = Circle(5.0)
print(circle.area) # Prints: Computing area..., then 78.53975
print(circle.area) # Just prints: 78.53975 (from cache)

The @property decorator creates a descriptor:

property_descriptor.py
class Temperature:
"""Temperature class using @property (which uses descriptors)"""
def __init__(self, celsius: float):
self._celsius = celsius
@property
def celsius(self) -> float:
"""Get temperature in Celsius"""
return self._celsius
@celsius.setter
def celsius(self, value: float):
"""Set temperature in Celsius"""
if value < -273.15:
raise ValueError("Temperature cannot be below absolute zero")
self._celsius = value
@property
def fahrenheit(self) -> float:
"""Get temperature in Fahrenheit (read-only)"""
return self._celsius * 9/5 + 32
@fahrenheit.setter
def fahrenheit(self, value: float):
"""Set temperature via Fahrenheit"""
self.celsius = (value - 32) * 5/9
# Usage
temp = Temperature(25)
print(temp.celsius) # 25
print(temp.fahrenheit) # 77.0
temp.fahrenheit = 100 # Sets via Fahrenheit
print(temp.celsius) # 37.777...
type_validation.py
class Typed:
"""Descriptor that enforces type checking"""
def __init__(self, name: str, expected_type: type):
self.name = name
self.expected_type = expected_type
def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj.__dict__.get(self.name)
def __set__(self, obj, value):
if not isinstance(value, self.expected_type):
raise TypeError(
f"{self.name} must be of type {self.expected_type.__name__}, "
f"got {type(value).__name__}"
)
obj.__dict__[self.name] = value
class Person:
name = Typed('name', str)
age = Typed('age', int)
height = Typed('height', float)
def __init__(self, name: str, age: int, height: float):
self.name = name
self.age = age
self.height = height
# Usage
person = Person("Alice", 25, 165.5) # Works
person.age = "25" # Raises TypeError
lazy_loading.py
class LazyProperty:
"""Descriptor that loads value only when accessed"""
def __init__(self, func):
self.func = func
self.name = func.__name__
def __get__(self, obj, objtype=None):
if obj is None:
return self
# Check if already loaded
if self.name in obj.__dict__:
return obj.__dict__[self.name]
# Load value
value = self.func(obj)
obj.__dict__[self.name] = value
return value
class Database:
def __init__(self, connection_string: str):
self.connection_string = connection_string
self._connection = None
@LazyProperty
def connection(self):
"""Lazy-load database connection"""
print("Connecting to database...")
# Simulate connection
return {"status": "connected", "db": "mydb"}
# Usage
db = Database("postgresql://localhost/db")
# Connection not created yet
print(db.connection) # Prints: Connecting to database..., then connection dict
print(db.connection) # Just returns connection (already loaded)
range_validation.py
class Bounded:
"""Descriptor that validates value is within range"""
def __init__(self, name: str, min_value: float = None, max_value: float = None):
self.name = name
self.min_value = min_value
self.max_value = max_value
def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj.__dict__.get(self.name)
def __set__(self, obj, value: float):
if self.min_value is not None and value < self.min_value:
raise ValueError(f"{self.name} must be >= {self.min_value}")
if self.max_value is not None and value > self.max_value:
raise ValueError(f"{self.name} must be <= {self.max_value}")
obj.__dict__[self.name] = value
class Percentage:
"""Percentage that must be between 0 and 100"""
value = Bounded('value', min_value=0.0, max_value=100.0)
def __init__(self, value: float):
self.value = value
# Usage
pct = Percentage(50.0) # Works
pct.value = 75.0 # Works
pct.value = 150.0 # Raises ValueError
pct.value = -10.0 # Raises ValueError
unit_conversion.py
class UnitConverter:
"""Descriptor that converts between units"""
def __init__(self, name: str, base_unit: str, conversion_factor: float):
self.name = name
self.base_unit = base_unit
self.conversion_factor = conversion_factor
def __get__(self, obj, objtype=None):
if obj is None:
return self
# Get value in base unit and convert
base_value = obj.__dict__.get(self.base_unit, 0)
return base_value * self.conversion_factor
def __set__(self, obj, value: float):
# Convert to base unit and store
base_value = value / self.conversion_factor
obj.__dict__[self.base_unit] = base_value
class Distance:
"""Distance with multiple unit support"""
def __init__(self, meters: float):
self.meters = meters
# Descriptors for different units
kilometers = UnitConverter('kilometers', 'meters', 0.001)
miles = UnitConverter('miles', 'meters', 0.000621371)
feet = UnitConverter('feet', 'meters', 3.28084)
# Usage
distance = Distance(1000) # 1000 meters
print(distance.kilometers) # 1.0 km
print(distance.miles) # 0.621371 miles
print(distance.feet) # 3280.84 feet
distance.kilometers = 2 # Set via kilometers
print(distance.meters) # 2000.0 meters

Both @staticmethod and @classmethod are implemented using descriptors:

static_class_descriptors.py
class StaticMethod:
"""How @staticmethod works (simplified)"""
def __init__(self, func):
self.func = func
def __get__(self, obj, objtype=None):
# Always return the function unchanged
return self.func
class ClassMethod:
"""How @classmethod works (simplified)"""
def __init__(self, func):
self.func = func
def __get__(self, obj, objtype=None):
# Bind function to class (not instance)
if objtype is None:
objtype = type(obj)
def bound_func(*args, **kwargs):
return self.func(objtype, *args, **kwargs)
return bound_func
class MyClass:
@StaticMethod
def static_method(x):
"""Static method - no self or cls"""
return x * 2
@ClassMethod
def class_method(cls, x):
"""Class method - receives cls"""
return f"{cls.__name__}: {x}"
# Usage
print(MyClass.static_method(5)) # 10
print(MyClass.class_method(5)) # MyClass: 5

Descriptors have a specific lookup order:

  1. Data descriptors (highest priority)
  2. Instance dictionary
  3. Non-data descriptors
  4. Class dictionary
  5. __getattr__ (if defined)
descriptor_precedence.py
class DataDescriptor:
"""Data descriptor - highest priority"""
def __init__(self, name):
self.name = name
def __get__(self, obj, objtype=None):
return f"DataDescriptor: {self.name}"
def __set__(self, obj, value):
obj.__dict__[f"_{self.name}"] = value
class NonDataDescriptor:
"""Non-data descriptor - lower priority"""
def __init__(self, name):
self.name = name
def __get__(self, obj, objtype=None):
return f"NonDataDescriptor: {self.name}"
class MyClass:
data_attr = DataDescriptor('data_attr')
non_data_attr = NonDataDescriptor('non_data_attr')
obj = MyClass()
obj.non_data_attr = "Instance value" # Can override non-data descriptor
print(obj.data_attr) # DataDescriptor: data_attr (descriptor wins)
print(obj.non_data_attr) # Instance value (instance dict wins)
storing_in_descriptor.py
# ❌ Bad - shared state across instances
class BadDescriptor:
def __init__(self):
self.value = None # Shared across all instances!
def __get__(self, obj, objtype=None):
return self.value
def __set__(self, obj, value):
self.value = value # All instances share same value!
# ✅ Good - store in instance dictionary
class GoodDescriptor:
def __init__(self, name):
self.name = name
def __get__(self, obj, objtype=None):
return obj.__dict__.get(self.name)
def __set__(self, obj, value):
obj.__dict__[self.name] = value # Each instance has own value
class_access.py
# ❌ Bad - doesn't handle class access
class BadDescriptor:
def __get__(self, obj, objtype=None):
return obj.__dict__[self.name] # Fails when obj is None
# ✅ Good - handles both instance and class access
class GoodDescriptor:
def __get__(self, obj, objtype=None):
if obj is None:
return self # Return descriptor when accessed on class
return obj.__dict__.get(self.name)
storing_name.py
# ❌ Bad - hardcoded name
class BadDescriptor:
def __set__(self, obj, value):
obj.__dict__['value'] = value # Hardcoded name
# ✅ Good - store name during initialization
class GoodDescriptor:
def __init__(self, name):
self.name = name # Store the attribute name
def __set__(self, obj, value):
obj.__dict__[self.name] = value # Use stored name
  1. Store values in instance dictionary - Not in descriptor itself
  2. Handle class access - Check if obj is None in __get__
  3. Use descriptive names - Make descriptor purpose clear
  4. Document behavior - Explain what the descriptor does
  5. Consider data vs non-data - Choose based on your needs
  6. Test thoroughly - Descriptors can be tricky to debug
  • Descriptors control attribute access via __get__, __set__, __delete__
  • Data descriptors implement __set__ or __delete__ (higher priority)
  • Non-data descriptors only implement __get__ (lower priority)
  • @property is a descriptor - powers property decorator
  • @staticmethod/@classmethod use descriptors
  • Store values in instance dict - Not in descriptor itself
  • Handle class access - Check obj is None in __get__
  • Reusable - Write once, use in multiple classes

Remember: Descriptors give you fine-grained control over attribute access - they’re the power behind @property and many other Python features! 🎯