Python Descriptors: Complete Interview Guide
Table of Contents
- Introduction to Descriptors
- Descriptor Protocol
- Types of Descriptors
- Built-in Descriptors
- Custom Descriptors
- Real-world Examples
- Best Practices
- Common Interview Questions
- Advanced Topics
Introduction to Descriptors
What are Descriptors?
Descriptors are Python objects that define how attribute access is handled. They are a fundamental mechanism that powers many of Python’s built-in features like properties, methods, static methods, and class methods.
Key Points:
- Descriptors are objects that define
__get__,__set__, or__delete__methods - They control how attributes are accessed, set, or deleted
- They enable computed attributes, validation, and other advanced behaviors
- They are the foundation for properties, methods, and other Python features
Why Use Descriptors?
- Computed Properties: Create attributes that are calculated on-the-fly
- Validation: Ensure data integrity when setting attributes
- Lazy Loading: Defer expensive operations until needed
- Caching: Store computed values for performance
- Logging/Tracking: Monitor attribute access and changes
Descriptor Protocol
The descriptor protocol consists of three methods:
class Descriptor:
def __get__(self, obj, objtype=None):
"""Called when attribute is accessed"""
pass
def __set__(self, obj, value):
"""Called when attribute is set"""
pass
def __delete__(self, obj):
"""Called when attribute is deleted"""
pass
Method Signatures
__get__(self, obj, objtype=None): Called for attribute accessobj: The instance (None if accessed on class)objtype: The class of the instance
__set__(self, obj, value): Called for attribute assignment__delete__(self, obj): Called for attribute deletion
Types of Descriptors
1. Data Descriptors
Data descriptors define both __get__ and __set__ methods. They have higher priority than instance dictionaries.
class DataDescriptor:
def __init__(self, name):
self.name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
return f"Data descriptor: {obj.__dict__.get(self.name, 'Not set')}"
def __set__(self, obj, value):
obj.__dict__[self.name] = value
class MyClass:
attr = DataDescriptor('attr')
# Usage
obj = MyClass()
obj.attr = "Hello" # Calls __set__
print(obj.attr) # Calls __get__
2. Non-Data Descriptors
Non-data descriptors only define __get__. They have lower priority than instance dictionaries.
class NonDataDescriptor:
def __init__(self, name):
self.name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
return f"Non-data descriptor: {obj.__dict__.get(self.name, 'Not set')}"
class MyClass:
attr = NonDataDescriptor('attr')
# Usage
obj = MyClass()
obj.attr = "Hello" # Direct assignment to instance dict
print(obj.attr) # Still calls __get__ (descriptor wins)
Built-in Descriptors
Properties
Properties are the most common use of descriptors:
class Person:
def __init__(self, first_name, last_name):
self._first_name = first_name
self._last_name = last_name
@property
def full_name(self):
"""Computed property"""
return f"{self._first_name} {self._last_name}"
@property
def age(self):
"""Read-only property"""
return self._age
@age.setter
def age(self, value):
"""Setter with validation"""
if value < 0:
raise ValueError("Age cannot be negative")
self._age = value
# Usage
person = Person("John", "Doe")
person.age = 30
print(person.full_name) # "John Doe"
print(person.age) # 30
Methods
Instance methods are descriptors:
class MyClass:
def instance_method(self):
return "Instance method"
@classmethod
def class_method(cls):
return "Class method"
@staticmethod
def static_method():
return "Static method"
# All of these are descriptors
print(MyClass.instance_method) # <function MyClass.instance_method>
print(MyClass.class_method) # <bound method MyClass.class_method>
print(MyClass.static_method) # <function MyClass.static_method>
Custom Descriptors
1. Validated Attribute Descriptor
class ValidatedAttribute:
def __init__(self, min_value=None, max_value=None, allowed_types=None):
self.min_value = min_value
self.max_value = max_value
self.allowed_types = allowed_types
self.name = None
def __set_name__(self, owner, name):
"""Called when descriptor is assigned to a class"""
self.name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj.__dict__.get(self.name)
def __set__(self, obj, value):
# Type validation
if self.allowed_types and not isinstance(value, self.allowed_types):
raise TypeError(f"{self.name} must be one of {self.allowed_types}")
# Value validation
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 Product:
price = ValidatedAttribute(min_value=0, allowed_types=(int, float))
name = ValidatedAttribute(allowed_types=(str,))
stock = ValidatedAttribute(min_value=0, max_value=1000, allowed_types=(int,))
# Usage
product = Product()
product.price = 29.99 # Valid
product.name = "Laptop" # Valid
product.stock = 50 # Valid
# These would raise exceptions:
# product.price = -10 # ValueError
# product.name = 123 # TypeError
# product.stock = 2000 # ValueError
2. Cached Property Descriptor
class CachedProperty:
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 value is already cached
if self.name not in obj.__dict__:
obj.__dict__[self.name] = self.func(obj)
return obj.__dict__[self.name]
class ExpensiveCalculation:
def __init__(self, data):
self.data = data
@CachedProperty
def expensive_result(self):
"""This expensive calculation will only run once"""
print("Performing expensive calculation...")
return sum(x * x for x in self.data)
# Usage
calc = ExpensiveCalculation([1, 2, 3, 4, 5])
print(calc.expensive_result) # Performs calculation
print(calc.expensive_result) # Uses cached result
3. Lazy Loading Descriptor
class LazyProperty:
def __init__(self, func):
self.func = func
self.name = func.__name__
def __get__(self, obj, objtype=None):
if obj is None:
return self
# Create the attribute if it doesn't exist
if self.name not in obj.__dict__:
obj.__dict__[self.name] = self.func(obj)
return obj.__dict__[self.name]
class DatabaseConnection:
def __init__(self, connection_string):
self.connection_string = connection_string
self._connection = None
@LazyProperty
def connection(self):
"""Database connection is only created when first accessed"""
print("Creating database connection...")
# Simulate database connection
return f"Connected to {self.connection_string}"
# Usage
db = DatabaseConnection("postgresql://localhost/mydb")
# Connection not created yet
print("Connection not created yet")
print(db.connection) # Now connection is created
print(db.connection) # Uses existing connection
Real-world Examples
1. Django Model Fields
Django uses descriptors extensively for model fields:
class CharField:
def __init__(self, max_length=None, null=False, blank=False):
self.max_length = max_length
self.null = null
self.blank = blank
self.name = None
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj.__dict__.get(self.name)
def __set__(self, obj, value):
if value is None and not self.null:
raise ValueError(f"{self.name} cannot be null")
if value == "" and not self.blank:
raise ValueError(f"{self.name} cannot be blank")
if self.max_length and len(str(value)) > self.max_length:
raise ValueError(f"{self.name} too long")
obj.__dict__[self.name] = value
class User:
username = CharField(max_length=50, null=False, blank=False)
email = CharField(max_length=100, null=False, blank=False)
bio = CharField(max_length=500, null=True, blank=True)
2. Configuration Management
class ConfigValue:
def __init__(self, default=None, required=False, validator=None):
self.default = default
self.required = required
self.validator = validator
self.name = None
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
value = obj.__dict__.get(self.name, self.default)
if value is None and self.required:
raise ValueError(f"Required config value {self.name} not set")
return value
def __set__(self, obj, value):
if self.validator:
value = self.validator(value)
obj.__dict__[self.name] = value
class AppConfig:
database_url = ConfigValue(required=True)
debug = ConfigValue(default=False, validator=bool)
port = ConfigValue(default=8000, validator=int)
api_key = ConfigValue(required=True)
Best Practices
1. Use __set_name__ for Automatic Naming
class Descriptor:
def __set_name__(self, owner, name):
"""Automatically called when descriptor is assigned to a class"""
self.name = name
print(f"Descriptor {name} assigned to {owner.__name__}")
class MyClass:
attr = Descriptor() # __set_name__ is called automatically
2. Handle Class Access Properly
class Descriptor:
def __get__(self, obj, objtype=None):
if obj is None:
# Accessed on class, return descriptor itself
return self
# Accessed on instance, return value
return obj.__dict__.get(self.name)
3. Use Descriptors for Computed Properties
class ComputedProperty:
def __init__(self, func):
self.func = func
self.name = func.__name__
def __get__(self, obj, objtype=None):
if obj is None:
return self
return self.func(obj)
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
@ComputedProperty
def area(self):
return self.width * self.height
@ComputedProperty
def perimeter(self):
return 2 * (self.width + self.height)
4. Implement Proper Error Handling
class SafeDescriptor:
def __get__(self, obj, objtype=None):
try:
if obj is None:
return self
return obj.__dict__.get(self.name)
except Exception as e:
raise AttributeError(f"Error accessing {self.name}: {e}")
Common Interview Questions
Q1: What are descriptors and how do they work?
Descriptors are Python objects that define how attribute access is handled through the descriptor protocol (__get__, __set__, __delete__). They control how attributes are accessed, set, or deleted and are the foundation for properties, methods, and other Python features.
Key Points:
- They define the behavior of attribute access
- They can be data descriptors (with
__set__) or non-data descriptors (only__get__) - Data descriptors have higher priority than instance dictionaries
- They enable computed properties, validation, and other advanced behaviors
Q2: What’s the difference between data and non-data descriptors?
- Data descriptors: Define both
__get__and__set__methods. They have higher priority than instance dictionaries and always control attribute access. - Non-data descriptors: Only define
__get__method. They have lower priority than instance dictionaries and can be overridden by direct assignment to the instance.
Q3: How do properties work internally?
Properties are implemented using descriptors. The @property decorator creates a descriptor object that:
- Uses
__get__to call the getter method - Uses
__set__to call the setter method (if defined) - Uses
__delete__to call the deleter method (if defined)
Q4: When would you use a custom descriptor?
Custom descriptors are useful for:
- Validation: Ensuring data integrity when setting attributes
- Computed properties: Creating attributes that are calculated on-the-fly
- Lazy loading: Deferring expensive operations until needed
- Caching: Storing computed values for performance
- Logging/tracking: Monitoring attribute access and changes
- Configuration management: Managing application settings with validation
Q5: How do you implement a read-only descriptor?
class ReadOnlyDescriptor:
def __init__(self, value):
self.value = value
def __get__(self, obj, objtype=None):
if obj is None:
return self
return self.value
def __set__(self, obj, value):
raise AttributeError("Cannot modify read-only attribute")
def __delete__(self, obj):
raise AttributeError("Cannot delete read-only attribute")
class MyClass:
readonly_attr = ReadOnlyDescriptor("initial value")
Q6: What’s the __set_name__ method used for?
The __set_name__ method is automatically called when a descriptor is assigned to a class. It receives the owner class and the attribute name, allowing the descriptor to know what name it was assigned to. This is useful for:
- Storing the attribute name for later use
- Setting up internal state based on the attribute name
- Avoiding the need to manually specify the attribute name
Q7: How do descriptors relate to Python’s method resolution order (MRO)?
Descriptors are part of Python’s attribute lookup mechanism. When accessing an attribute, Python follows this order:
- Data descriptors on the class
- Instance dictionary
- Non-data descriptors on the class
- Class dictionary
- Parent classes (following MRO)
Q8: Can you give an example of a practical use case for descriptors?
class TypedAttribute:
def __init__(self, expected_type):
self.expected_type = expected_type
self.name = None
def __set_name__(self, owner, name):
self.name = name
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 {self.expected_type}")
obj.__dict__[self.name] = value
class Person:
name = TypedAttribute(str)
age = TypedAttribute(int)
height = TypedAttribute(float)
Advanced Topics
1. Descriptor Chaining
class LoggingDescriptor:
def __init__(self, descriptor):
self.descriptor = descriptor
def __get__(self, obj, objtype=None):
print(f"Getting {self.descriptor.name}")
return self.descriptor.__get__(obj, objtype)
def __set__(self, obj, value):
print(f"Setting {self.descriptor.name} to {value}")
return self.descriptor.__set__(obj, value)
class ValidatedAttribute:
def __init__(self, min_value=None, max_value=None):
self.min_value = min_value
self.max_value = max_value
self.name = None
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj.__dict__.get(self.name)
def __set__(self, obj, value):
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 MyClass:
# Chain descriptors together
value = LoggingDescriptor(ValidatedAttribute(min_value=0, max_value=100))
2. Descriptor Factories
def typed_attribute(expected_type, default=None):
"""Factory function for creating typed attributes"""
class TypedAttribute:
def __init__(self):
self.name = None
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj.__dict__.get(self.name, default)
def __set__(self, obj, value):
if value is not None and not isinstance(value, expected_type):
raise TypeError(f"{self.name} must be {expected_type}")
obj.__dict__[self.name] = value
return TypedAttribute()
class Config:
# Use factory to create typed attributes
port = typed_attribute(int, default=8000)
debug = typed_attribute(bool, default=False)
host = typed_attribute(str, default="localhost")
3. Metaclass Integration
class DescriptorMeta(type):
"""Metaclass that automatically sets up descriptors"""
def __new__(cls, name, bases, namespace):
# Find all descriptors and call __set_name__ if needed
for key, value in namespace.items():
if hasattr(value, '__set_name__'):
value.__set_name__(cls, key)
return super().__new__(cls, name, bases, namespace)
class MyClass(metaclass=DescriptorMeta):
attr = ValidatedAttribute(min_value=0)
# __set_name__ is automatically called
Summary
Descriptors are a powerful and fundamental feature of Python that enable:
- Computed properties and lazy evaluation
- Data validation and type checking
- Attribute access control and logging
- Framework development (like Django ORM)
- Clean, reusable code patterns
Understanding descriptors is essential for:
- Advanced Python development
- Framework and library creation
- Interview preparation for senior Python positions
- Understanding how Python’s built-in features work
The key is to recognize that descriptors are everywhere in Python - properties, methods, and many built-in features are all implemented using the descriptor protocol. Mastering descriptors opens up powerful possibilities for creating clean, maintainable, and flexible code.
Interview angle
- “What is a descriptor?” — an object defining
__get__,__set__or__delete__that, when assigned as a class attribute, intercepts attribute access on instances. It’s the mechanism behindproperty,classmethod,staticmethodand ORM fields. - “Data versus non-data descriptor?” — a data descriptor defines
__set__or__delete__and takes precedence over the instance__dict__; a non-data descriptor defines only__get__and is shadowed by an instance attribute. That precedence is exactly why@propertycan’t be overwritten on an instance while a cached method can. - “What’s the lookup order?” — type-level data descriptor, then instance
__dict__, then type-level non-data descriptor, then__getattr__. Knowing this explains most surprising attribute behaviour. - “When would you write one?” — reusable attribute behaviour across many fields: validation, type coercion, lazy loading, unit conversion. For a single attribute,
@propertyis simpler. Use__set_name__so the descriptor learns its own attribute name automatically.