Learn Python Series (#46) - Descriptors & Properties

in StemSocial18 hours ago

Learn Python Series (#46) - Descriptors & Properties

python-logo.png

Repository

What will I learn

  • You will learn what the descriptor protocol is and why it powers Python's attribute system;
  • how properties, methods, classmethods, and staticmethods all use descriptors;
  • when to write custom descriptors vs. using @property;
  • the difference between data and non-data descriptors (and why the distinction matters for attribute lookup);
  • how to build reusable attribute validation without boilerplate;
  • how frameworks like Django and SQLAlchemy use descriptors internally.

Requirements

  • A working modern computer running macOS, Windows or Ubuntu;
  • An installed Python 3(.11+) distribution, such as (for example) the Anaconda Distribution;
  • The ambition to learn Python programming.

Difficulty

  • Intermediate, advanced

Curriculum (of the Learn Python Series):

GitHub Account

https://github.com/realScipio

Learn Python Series (#46) - Descriptors & Properties

At the end of our previous episode on metaclasses, I mentioned that descriptors would be getting their own deep-dive. And here we are ;-) Because if metaclasses are the "deep magic" most people don't need, then descriptors are the "hidden wiring" that everyone already uses without realizing it. Every single @property you've ever written, every method you've ever called, every @classmethod and @staticmethod you've ever decorated — all of them work through the descriptor protocol.

Ever wonder how @property actually works? Or why @staticmethod and @classmethod behave differently even though they're both decorators? Or how Django model fields like CharField(max_length=200) magically validate data when you assign to them?

The answer to all of these is the same: descriptors. They're the hidden protocol behind attribute access in Python — and once you understand them, about a dozen "magical" Python behaviors suddenly make complete sense.

Nota bene: You've been using descriptors since your first Python class. Every method call, every property, every @staticmethod works through descriptors. This episode makes the invisible visible. And that's precisely why I love this topic — it reveals the machinery inside Python's object model that most tutorials skip over entirely.

The problem descriptors solve

Let's start with a simple, practical problem. Suppose you want an attribute that validates its value whenever it gets set:

class Temperature:
    def __init__(self, celsius):
        if celsius < -273.15:
            raise ValueError("Temperature below absolute zero")
        self.celsius = celsius

This validates in __init__, but nothing stops someone from doing t.celsius = -500 afterward. The validation only runs once — at construction time. That's a problem, because your object can silently end up in an invalid state.

The traditional OOP approach is to use getter/setter methods:

class Temperature:
    def __init__(self, celsius):
        self.set_celsius(celsius)
    
    def get_celsius(self):
        return self._celsius
    
    def set_celsius(self, value):
        if value < -273.15:
            raise ValueError("Temperature below absolute zero")
        self._celsius = value

But now you've lost the elegant syntax. It's t.set_celsius(20) instead of the natural t.celsius = 20. Coming from Java, this getter/setter pattern might feel normal (it's all you have in Java). But in Python, we can do better. Much better.

The mental model: you want attribute syntax with method-like control. You want t.celsius = 20 to look like a simple assignment, but to actually trigger validation logic behind the scenes. Descriptors provide exactly this.

The descriptor protocol

A descriptor is any object that defines at least one of __get__, __set__, or __delete__:

class Descriptor:
    def __get__(self, obj, objtype=None):
        # Called when the attribute is READ
        pass
    
    def __set__(self, obj, value):
        # Called when the attribute is SET
        pass
    
    def __delete__(self, obj):
        # Called when the attribute is DELETED
        pass

That's it — that's the whole protocol. Three dunder methods. When a descriptor instance is placed as a class attribute, Python intercepts access to that attribute and routes it through these methods:

class Temperature:
    celsius = ValidatedTemperature()  # Descriptor instance as class attribute

t = Temperature()
t.celsius = 20      # calls ValidatedTemperature.__set__(descriptor, t, 20)
print(t.celsius)    # calls ValidatedTemperature.__get__(descriptor, t, Temperature)
del t.celsius       # calls ValidatedTemperature.__delete__(descriptor, t)

The key insight: the descriptor sits on the class, not on the instance. But it receives the instance as an argument, so it can store per-instance data. This is what enables reusable attribute behavior — one descriptor class controlling attributes across many instances and even many different classes.

Writing a proper validation descriptor

Let's build that temperature validator properly:

class ValidatedTemperature:
    def __init__(self):
        self.data = {}
    
    def __set_name__(self, owner, name):
        # Called automatically when descriptor is assigned to class
        # owner = the class, name = the attribute name
        self.name = name
    
    def __get__(self, obj, objtype=None):
        if obj is None:
            return self  # Accessed from the class itself
        return self.data.get(id(obj))
    
    def __set__(self, obj, value):
        if value < -273.15:
            raise ValueError(
                f"{self.name}: {value} is below absolute zero (-273.15°C)"
            )
        self.data[id(obj)] = value

class Temperature:
    celsius = ValidatedTemperature()
    
    def __init__(self, celsius):
        self.celsius = celsius  # Triggers __set__!

t = Temperature(20)
print(t.celsius)      # 20
t.celsius = 25        # OK, triggers __set__ with validation
t.celsius = -300      # ValueError: celsius: -300 is below absolute zero

Several things to notice here:

First, the __set_name__ hook (Python 3.6+): Python calls this automatically when the descriptor is assigned to a class attribute. The descriptor receives both the owner class and the attribute name. This is brilliant — the descriptor "knows" what it's called without you having to tell it. Before 3.6, you had to pass the name manually: celsius = ValidatedTemperature("celsius") — redundant and error-prone.

Second, the if obj is None check in __get__: when you access the descriptor from the class (not an instance), obj is None. Returning self in that case lets you inspect the descriptor object directly via Temperature.celsius.

Third, storing data in self.data keyed by id(obj): this is a simple approach, but it has a problem we'll address shortly (memory leaks). Bear with me ;-)

Data vs. non-data descriptors

This is the distinction that trips people up — and it's critical for understanding Python's attribute lookup order.

A data descriptor defines __set__ and/or __delete__ (in addition to optionally __get__). A non-data descriptor only defines __get__.

Why does this matter? Because Python's attribute lookup follows a very specific priority order:

  1. Data descriptors from the class (and its MRO chain)
  2. The instance's own __dict__
  3. Non-data descriptors from the class (and its MRO chain)
  4. __getattr__ if defined (as a fallback)

Let me demonstrate this with code, because the difference is subtle but enormously important:

class DataDescriptor:
    """Has __set__, so it's a data descriptor."""
    def __get__(self, obj, objtype=None):
        print("DataDescriptor.__get__ called")
        return "from data descriptor"
    
    def __set__(self, obj, value):
        print(f"DataDescriptor.__set__ called with {value}")

class NonDataDescriptor:
    """Only has __get__, so it's a non-data descriptor."""
    def __get__(self, obj, objtype=None):
        print("NonDataDescriptor.__get__ called")
        return "from non-data descriptor"

class MyClass:
    data_attr = DataDescriptor()
    nondata_attr = NonDataDescriptor()

obj = MyClass()

# Data descriptor WINS over instance dict:
obj.__dict__['data_attr'] = "instance value"
print(obj.data_attr)
# DataDescriptor.__get__ called
# "from data descriptor"  ← descriptor wins!

# Non-data descriptor LOSES to instance dict:
obj.__dict__['nondata_attr'] = "instance value"
print(obj.nondata_attr)
# "instance value"  ← instance dict wins!

See the difference? Data descriptors take priority over instance attributes. Non-data descriptors yield to them. This design is intentional — it's what makes methods overridable and properties enforced.

Think about it: @property creates a data descriptor (it has __set__), so it always takes precedence. But regular methods are functions, and functions are non-data descriptors (they only have __get__), which means you can override a method on an individual instance by sticking something in its __dict__. You'd rarely want to, but Python's attribute protocol allows it.

How methods are descriptors

This is one of those "aha!" moments in Python. Functions are non-data descriptors — they implement __get__. When you define a method in a class:

class MyClass:
    def greet(self, name):
        return f"Hello, {name}!"

# greet is just a function:
print(type(MyClass.__dict__['greet']))  # <class 'function'>

# But when accessed via instance, it becomes a bound method:
obj = MyClass()
print(type(obj.greet))  # <class 'method'>
print(obj.greet)        # <bound method MyClass.greet of <MyClass object>>

When you access obj.greet, Python calls function.__get__(obj, MyClass), which returns a bound method — a callable that has obj pre-filled as the first argument. That's why self works. There's no special compiler magic, no keyword processing — just the descriptor protocol doing its thing.

You can even call __get__ manually to see this in action:

func = MyClass.__dict__['greet']  # The raw function

# Manually create a bound method:
bound = func.__get__(obj, MyClass)
print(bound("World"))  # Hello, World!

# Equivalent to:
print(obj.greet("World"))  # Hello, World!

This also explains the difference between @staticmethod and @classmethod. Both are descriptors that modify how __get__ works:

class Demo:
    @staticmethod
    def static_method():
        return "I'm static"
    
    @classmethod
    def class_method(cls):
        return f"I'm bound to {cls.__name__}"

# staticmethod's __get__ returns the raw function, unmodified:
print(Demo.__dict__['static_method'].__get__(None, Demo))
# <function Demo.static_method at 0x...>

# classmethod's __get__ returns a bound method with cls as first arg:
print(Demo.__dict__['class_method'].__get__(None, Demo))
# <bound method Demo.class_method of <class 'Demo'>>

staticmethod.__get__ strips the binding entirely — the function receives no implicit first argument. classmethod.__get__ binds the class (not the instance) as the first argument. Both are just descriptors with different __get__ implementations. Elegant, isn't it? ;-)

Properties: the built-in descriptor

@property is Python's most commonly used descriptor, and now you have the context to understand exactly what it does:

class Temperature:
    def __init__(self, celsius):
        self._celsius = celsius
    
    @property
    def celsius(self):
        """The temperature in Celsius."""
        return self._celsius
    
    @celsius.setter
    def celsius(self, value):
        if value < -273.15:
            raise ValueError("Temperature below absolute zero")
        self._celsius = value
    
    @celsius.deleter
    def celsius(self):
        print("Resetting temperature")
        self._celsius = 0

t = Temperature(25)
print(t.celsius)     # 25 (calls getter)
t.celsius = 30       # calls setter with validation
del t.celsius        # calls deleter
print(t.celsius)     # 0

Under the hood, property() creates a data descriptor (it defines both __get__ and __set__) that delegates to your getter, setter, and deleter functions. Something like this (simplified):

class property_simplified:
    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 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__)

That setter method returns a new property with the same getter but the new setter — which is why the @celsius.setter decorator pattern works. Each decorator call creates a new property descriptor that accumulates the getter, setter, and deleter functions.

Use properties when the validation logic is specific to one class and one attribute. They're perfect for simple cases. But when you need the same logic across multiple attributes or multiple classes, you want a custom descriptor.

Reusable descriptors with __set_name__

Here's where descriptors really shine. Suppose multiple classes need type-checked attributes:

class Typed:
    """Reusable type-checking descriptor."""
    def __init__(self, expected_type):
        self.expected_type = expected_type
    
    def __set_name__(self, owner, name):
        self.name = name
        self.storage_name = f'_typed_{name}'
    
    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return getattr(obj, self.storage_name, 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.storage_name, value)

class Person:
    name = Typed(str)
    age = Typed(int)
    email = Typed(str)
    
    def __init__(self, name, age, email):
        self.name = name
        self.age = age
        self.email = email

class Product:
    title = Typed(str)
    price = Typed(float)
    quantity = Typed(int)
    
    def __init__(self, title, price, quantity):
        self.title = title
        self.price = price
        self.quantity = quantity

p = Person("Alice", 30, "[email protected]")
print(p.name, p.age)  # Alice 30

try:
    p.age = "thirty"
except TypeError as e:
    print(e)  # age must be int, got str

try:
    Product("Widget", "cheap", 5)
except TypeError as e:
    print(e)  # price must be float, got str

One descriptor class, used across multiple classes, multiple attributes, zero boilerplate repetition. Try achieving that with @property — you'd need six separate getter/setter pairs. With descriptors, the validation logic is written once and reused everywhere.

Notice I'm storing per-instance data using setattr(obj, self.storage_name, value) — the descriptor writes to the instance's own __dict__ under a prefixed name (_typed_age, _typed_name, etc.). This approach is cleaner than the id(obj) dictionary we used earlier and doesn't leak memory.

The WeakKeyDictionary pattern

If you do want to store data inside the descriptor itself (rather than on the instance), you need to be careful about memory. Using a plain dict keyed by id(obj) creates memory leaks — the values stick around even after the objects are garbage collected, because the dictionary still holds references.

The solution is WeakKeyDictionary:

from weakref import WeakKeyDictionary

class Validated:
    def __init__(self, validator, error_msg="Invalid value"):
        self.validator = validator
        self.error_msg = error_msg
        self.data = WeakKeyDictionary()
    
    def __set_name__(self, owner, name):
        self.name = name
    
    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return self.data.get(obj)
    
    def __set__(self, obj, value):
        if not self.validator(value):
            raise ValueError(f"{self.name}: {self.error_msg} (got {value!r})")
        self.data[obj] = value

class Account:
    balance = Validated(
        lambda x: isinstance(x, (int, float)) and x >= 0,
        "must be a non-negative number"
    )
    name = Validated(
        lambda x: isinstance(x, str) and len(x) >= 2,
        "must be a string of at least 2 characters"
    )
    
    def __init__(self, name, balance=0):
        self.name = name
        self.balance = balance

acc = Account("Savings", 1000)
print(acc.balance)  # 1000

try:
    acc.balance = -50
except ValueError as e:
    print(e)  # balance: must be a non-negative number (got -50)

WeakKeyDictionary holds weak references to keys, so when an Account instance is garbage collected, its entry is automatically cleaned up. No memory leaks. This matters in long-running applications where objects are created and destroyed frequently.

When to use which storage pattern:

  • Instance __dict__ (via setattr): simplest, works with __slots__, no memory leak concern. Preferred in most cases.
  • WeakKeyDictionary: when you need the descriptor to "own" the data, or when you can't modify the instance's __dict__ (e.g. working with frozen dataclasses or objects with __slots__ that don't include your storage name).

Lazy properties: caching expensive computations

Here's a pattern I use all the time. Sometimes you have an attribute that's expensive to compute, and you only want to compute it once — on first access:

class LazyProperty:
    """Non-data descriptor that caches on first access."""
    def __init__(self, func):
        self.func = func
        self.name = func.__name__
        self.__doc__ = func.__doc__
    
    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        # Compute value and store it directly on the instance
        value = self.func(obj)
        setattr(obj, self.name, value)
        return value

class DataAnalysis:
    def __init__(self, raw_data):
        self.raw_data = raw_data
    
    @LazyProperty
    def mean(self):
        """Compute mean (expensive for large datasets)."""
        print("  Computing mean...")
        return sum(self.raw_data) / len(self.raw_data)
    
    @LazyProperty
    def variance(self):
        """Compute variance."""
        print("  Computing variance...")
        m = self.mean  # Triggers mean computation if not cached
        return sum((x - m) ** 2 for x in self.raw_data) / len(self.raw_data)
    
    @LazyProperty
    def std_dev(self):
        """Compute standard deviation."""
        print("  Computing std_dev...")
        return self.variance ** 0.5

analysis = DataAnalysis([2, 4, 4, 4, 5, 5, 7, 9])

print(analysis.std_dev)
#   Computing std_dev...
#   Computing variance...
#   Computing mean...
#   4.0  ← mean cached
#   3.5  ← variance cached
# 2.0    ← std_dev

print(analysis.std_dev)
# 2.0    ← all cached, no recomputation!

print(analysis.mean)
# 4.0    ← already cached

The trick is elegant: LazyProperty is a non-data descriptor (it only defines __get__, no __set__). Remember the lookup order? Non-data descriptors lose to instance attributes. So on first access, the descriptor computes the value and stores it directly in the instance's __dict__ via setattr. On subsequent accesses, Python finds the value in the instance __dict__ first and never calls __get__ again. The descriptor effectively replaces itself with the computed value.

This is used extensively in real-world codebases. Python 3.8 even added functools.cached_property which does exactly this (with some thread-safety added).

from functools import cached_property

class DataAnalysis:
    def __init__(self, raw_data):
        self.raw_data = raw_data
    
    @cached_property
    def mean(self):
        return sum(self.raw_data) / len(self.raw_data)

Same idea, but in the standard library. Good to know the underlying mechanism, though — understanding why cached_property works is more valuable than just knowing how to use it.

Building a composable descriptor: chaining validators

You can combine multiple validation rules using descriptor composition:

class RangeChecked:
    """Validates that a value falls within a numeric range."""
    def __init__(self, min_val=None, max_val=None):
        self.min_val = min_val
        self.max_val = max_val
    
    def __set_name__(self, owner, name):
        self.name = name
        self.storage = f'_range_{name}'
    
    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return getattr(obj, self.storage, None)
    
    def __set__(self, obj, value):
        if self.min_val is not None and value < self.min_val:
            raise ValueError(
                f"{self.name} must be >= {self.min_val}, got {value}"
            )
        if self.max_val is not None and value > self.max_val:
            raise ValueError(
                f"{self.name} must be <= {self.max_val}, got {value}"
            )
        setattr(obj, self.storage, value)

class Sensor:
    temperature = RangeChecked(min_val=-40, max_val=125)
    humidity = RangeChecked(min_val=0, max_val=100)
    pressure = RangeChecked(min_val=300, max_val=1100)
    
    def __init__(self, temperature, humidity, pressure):
        self.temperature = temperature
        self.humidity = humidity
        self.pressure = pressure
    
    def __repr__(self):
        return (f"Sensor(temp={self.temperature}°C, "
                f"humidity={self.humidity}%, "
                f"pressure={self.pressure}hPa)")

s = Sensor(22.5, 65, 1013)
print(s)  # Sensor(temp=22.5°C, humidity=65%, pressure=1013hPa)

try:
    s.temperature = 200  # Exceeds max for this sensor type
except ValueError as e:
    print(e)  # temperature must be <= 125, got 200

try:
    s.humidity = -5
except ValueError as e:
    print(e)  # humidity must be >= 0, got -5

Each RangeChecked descriptor independently validates its own attribute. The class definition reads almost like a specification sheet: temperature ranges from -40 to 125, humidity from 0 to 100, pressure from 300 to 1100. Self-documenting code.

Real-world pattern: ORM-style field descriptors

If you've used Django or SQLAlchemy, you've been using descriptors without knowing it. Here's a simplified version of how ORM fields work:

class Field:
    """Base ORM field descriptor."""
    _creation_counter = 0
    
    def __init__(self, column_type, nullable=False, default=None):
        self.column_type = column_type
        self.nullable = nullable
        self.default = default
        # Track creation order for consistent column ordering
        self._order = Field._creation_counter
        Field._creation_counter += 1
    
    def __set_name__(self, owner, name):
        self.name = name
        self.storage = f'_field_{name}'
        # Register this field on the class
        if not hasattr(owner, '_fields'):
            owner._fields = {}
        owner._fields[name] = self
    
    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return getattr(obj, self.storage, self.default)
    
    def __set__(self, obj, value):
        if value is None and not self.nullable:
            raise ValueError(f"{self.name} cannot be NULL")
        setattr(obj, self.storage, value)
    
    def __repr__(self):
        return f"Field({self.column_type}, name={self.name!r})"

class CharField(Field):
    def __init__(self, max_length, **kwargs):
        super().__init__("VARCHAR", **kwargs)
        self.max_length = max_length
    
    def __set__(self, obj, value):
        if value is not None:
            if not isinstance(value, str):
                raise TypeError(f"{self.name} must be a string")
            if len(value) > self.max_length:
                raise ValueError(
                    f"{self.name} exceeds max_length of {self.max_length}"
                )
        super().__set__(obj, value)

class IntegerField(Field):
    def __init__(self, **kwargs):
        super().__init__("INTEGER", **kwargs)
    
    def __set__(self, obj, value):
        if value is not None and not isinstance(value, int):
            raise TypeError(f"{self.name} must be an integer")
        super().__set__(obj, value)

class User:
    id = IntegerField()
    name = CharField(max_length=100)
    email = CharField(max_length=255, nullable=True)
    
    def __init__(self, **kwargs):
        for key, value in kwargs.items():
            setattr(self, key, value)

user = User(id=1, name="Scipio", email="[email protected]")
print(user.name)   # Scipio
print(user.email)  # [email protected]

# Type checking:
try:
    user.id = "not an integer"
except TypeError as e:
    print(e)  # id must be an integer

# Length checking:
try:
    user.name = "x" * 200
except ValueError as e:
    print(e)  # name exceeds max_length of 100

# Nullable checking:
try:
    user.name = None
except ValueError as e:
    print(e)  # name cannot be NULL

# The class knows its fields:
print(User._fields)
# {'id': Field(INTEGER, name='id'), 
#  'name': Field(VARCHAR, name='name'),
#  'email': Field(VARCHAR, name='email')}

That _fields registry (built automatically via __set_name__) is exactly how Django's ModelBase metaclass collects fields. The metaclass iterates _fields to generate SQL CREATE TABLE statements, build query APIs, create migration files, and more. Descriptors do the per-attribute work; the metaclass orchestrates the class-level work. They're complementary tools.

Descriptors vs. __getattr__ and __getattribute__

One more thing worth clarifying: how do descriptors relate to __getattr__ and __getattribute__?

  • __getattribute__ is called on every attribute access. It's what implements the descriptor lookup logic. If you override it, you bypass descriptors entirely (usually a bad idea).
  • __getattr__ is only called when normal lookup fails — it's a fallback. Descriptors execute before __getattr__ gets a chance.
class Traced:
    """Descriptor that logs access."""
    def __set_name__(self, owner, name):
        self.name = name
        self.storage = f'_traced_{name}'
    
    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        value = getattr(obj, self.storage, "UNSET")
        print(f"  [GET] {self.name} = {value}")
        return value
    
    def __set__(self, obj, value):
        print(f"  [SET] {self.name} = {value}")
        setattr(obj, self.storage, value)

class Config:
    debug = Traced()
    verbose = Traced()
    
    def __getattr__(self, name):
        print(f"  [FALLBACK] __getattr__ for {name}")
        return f"default_{name}"

cfg = Config()
cfg.debug = True       # [SET] debug = True
print(cfg.debug)       # [GET] debug = True  → True
print(cfg.missing)     # [FALLBACK] __getattr__ for missing → default_missing

The descriptor handles debug and verbose. Anything else falls through to __getattr__. Clean separation of concerns.

Descriptors and __slots__

One gotcha: if a class uses __slots__, you can't store descriptor data on the instance using arbitrary attribute names (because __slots__ restricts which attributes an instance can have). The solution is to include your storage names in __slots__:

class Positive:
    def __set_name__(self, owner, name):
        self.name = name
        self.storage = f'_pos_{name}'
    
    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return getattr(obj, self.storage, 0)
    
    def __set__(self, obj, value):
        if value < 0:
            raise ValueError(f"{self.name} must be positive")
        setattr(obj, self.storage, value)

class Point:
    __slots__ = ('_pos_x', '_pos_y')
    x = Positive()
    y = Positive()
    
    def __init__(self, x, y):
        self.x = x
        self.y = y

p = Point(3, 4)
print(p.x, p.y)  # 3 4

Alternatively, use the WeakKeyDictionary pattern — that stores data on the descriptor side, so it doesn't care about __slots__ at all.

When to use what: the decision tree

After covering all these patterns, here's my practical recommendation:

Use @property when:

  • The logic is specific to one attribute in one class
  • You want a simple getter/setter/deleter
  • No reuse needed

Use a custom descriptor when:

  • Multiple attributes need the same validation/behavior
  • Multiple classes share the same attribute pattern
  • You're building a library or framework
  • You need __set_name__ for automatic setup

Use @cached_property (or LazyProperty) when:

  • The attribute is expensive to compute
  • It only needs to be computed once per instance
  • The result doesn't change

Use __getattr__ when:

  • You need a fallback for truly dynamic/unknown attributes
  • Attribute names aren't known at class definition time

Use metaclasses when:

  • You need to intercept class creation (not just attribute access)
  • Descriptors + __init_subclass__ aren't enough

In practice, properties handle 80% of cases, custom descriptors handle 15%, and the remaining 5% is split between cached properties, __getattr__, and metaclasses. Know all five, reach for the simplest one that works ;-)

Samengevat

In this episode, we explored descriptors and their role in Python's attribute system:

  • Descriptors define __get__, __set__, and/or __delete__ to control attribute access
  • Data descriptors (with __set__) take priority over instance __dict__ in attribute lookup
  • Non-data descriptors (only __get__) yield to instance attributes — which is why LazyProperty works
  • Methods are non-data descriptors: function.__get__ creates bound methods, explaining how self works
  • @property is a data descriptor that delegates to getter/setter/deleter functions
  • @staticmethod and @classmethod are descriptors with different __get__ behavior
  • __set_name__ (Python 3.6+) lets descriptors know their own attribute name automatically
  • Custom descriptors enable reusable validation logic across classes and attributes
  • WeakKeyDictionary prevents memory leaks when storing data inside the descriptor
  • Lazy properties use the non-data descriptor trick to cache values on first access
  • ORM field systems (Django, SQLAlchemy) are built on descriptors combined with metaclasses
  • Descriptors reveal that most of Python's "magic" is just protocol implementation — clean, composable, and extensible

Understanding descriptors connects the dots between properties, methods, classmethods, ORMs, and the attribute lookup chain. They're the glue that holds Python's object model together.

Appreciate you sticking around. Until next time!

@scipio