#1 Data Analytics Program in India
₹2,499₹1,499Enroll Now
8 min read
•Question 29 of 41hard

Descriptors in Python

Customizing attribute access.

What You'll Learn

  • What descriptors are and why they matter
  • The descriptor protocol (get, set, delete)
  • Data vs non-data descriptors
  • Practical use cases: validation, lazy properties, ORM fields
  • How property() works under the hood

What are Descriptors?

A descriptor is any object that defines __get__, __set__, or __delete__. Descriptors let you customize what happens when an attribute is accessed, set, or deleted.

code.pyPython
class Descriptor:
    def __get__(self, obj, objtype=None):
        print(f"__get__ called: obj={obj}, type={objtype}")
        return "value from descriptor"

    def __set__(self, obj, value):
        print(f"__set__ called: setting {value}")

    def __delete__(self, obj):
        print("__delete__ called")

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

obj = MyClass()

# Accessing triggers __get__
print(obj.attr)
# __get__ called: obj=<MyClass object>, type=<class 'MyClass'>
# value from descriptor

# Setting triggers __set__
obj.attr = 42
# __set__ called: setting 42

# Deleting triggers __delete__
del obj.attr
# __delete__ called

Data vs Non-Data Descriptors

TypeMethodsPriority
Data descriptorget + set (or delete)Highest - overrides instance dict
Non-data descriptorget onlyLower - instance dict takes precedence
code.pyPython
class DataDescriptor:
    def __get__(self, obj, objtype=None):
        return "data descriptor"
    def __set__(self, obj, value):
        pass  # Just having __set__ makes it a data descriptor

class NonDataDescriptor:
    def __get__(self, obj, objtype=None):
        return "non-data descriptor"

class MyClass:
    data = DataDescriptor()
    nondata = NonDataDescriptor()

obj = MyClass()

# Data descriptor always wins
obj.__dict__['data'] = "instance value"
print(obj.data)  # "data descriptor" (descriptor wins)

# Non-data descriptor loses to instance dict
obj.__dict__['nondata'] = "instance value"
print(obj.nondata)  # "instance value" (instance wins)

Practical Use Cases

Type Validation

code.pyPython
class Typed:
    def __init__(self, name, expected_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 {self.expected_type.__name__}, "
                f"got {type(value).__name__}"
            )
        obj.__dict__[self.name] = value

class Person:
    name = Typed('name', str)
    age = Typed('age', int)

    def __init__(self, name, age):
        self.name = name
        self.age = age

p = Person("Alice", 30)  # OK
print(p.name, p.age)     # Alice 30

# p = Person("Bob", "thirty")
# TypeError: age must be int, got str

Range Validation

code.pyPython
class Ranged:
    def __init__(self, name, min_val, max_val):
        self.name = name
        self.min_val = min_val
        self.max_val = max_val

    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 self.min_val <= value <= self.max_val:
            raise ValueError(
                f"{self.name} must be between {self.min_val} and {self.max_val}"
            )
        obj.__dict__[self.name] = value

class Temperature:
    celsius = Ranged('celsius', -273.15, 1000)

temp = Temperature()
temp.celsius = 25      # OK
# temp.celsius = -300  # ValueError

Lazy Property (Cached)

code.pyPython
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

        # Compute and cache in instance dict
        value = self.func(obj)
        obj.__dict__[self.name] = value  # Replace descriptor access
        return value

class DataAnalyzer:
    def __init__(self, data):
        self.data = data

    @LazyProperty
    def statistics(self):
        print("Computing statistics (expensive)...")
        return {
            'sum': sum(self.data),
            'avg': sum(self.data) / len(self.data),
            'max': max(self.data),
            'min': min(self.data)
        }

analyzer = DataAnalyzer([1, 2, 3, 4, 5])

print(analyzer.statistics)
# Computing statistics (expensive)...
# {'sum': 15, 'avg': 3.0, 'max': 5, 'min': 1}

print(analyzer.statistics)  # No computation - cached!
# {'sum': 15, 'avg': 3.0, 'max': 5, 'min': 1}

ORM-Style Fields

code.pyPython
class Field:
    def __init__(self, column_name=None):
        self.column_name = column_name

    def __set_name__(self, owner, name):
        # Called automatically when class is created
        self.name = name
        if self.column_name is None:
            self.column_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):
        obj.__dict__[self.name] = value

class Model:
    id = Field(column_name='_id')
    name = Field()

user = Model()
user.id = 1
user.name = "Alice"

print(Model.id.column_name)   # '_id'
print(Model.name.column_name) # 'name'

How property() Works

The built-in property is a descriptor:

code.pyPython
class Property:
    """Simplified property implementation."""

    def __init__(self, fget=None, fset=None, fdel=None, doc=None):
        self.fget = fget
        self.fset = fset
        self.fdel = fdel
        self.__doc__ = doc

    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 setter(self, fset):
        return type(self)(self.fget, fset, self.fdel, self.__doc__)

# Real property usage
class Circle:
    def __init__(self, radius):
        self._radius = radius

    @property
    def radius(self):
        return self._radius

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

    @property
    def area(self):
        import math
        return math.pi * self._radius ** 2

Interview Tip

When asked about descriptors:

  1. Descriptors customize attribute access via get/set/delete
  2. Data descriptors (with set) take precedence over instance dict
  3. Non-data descriptors (only get) are overridden by instance dict
  4. property, classmethod, staticmethod are all descriptors
  5. Use set_name (Python 3.6+) to get the attribute name automatically