Learn Python Series (#46) - Descriptors & Properties

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):
- Learn Python Series - Intro
- Learn Python Series (#2) - Handling Strings Part 1
- Learn Python Series (#3) - Handling Strings Part 2
- Learn Python Series (#4) - Round-Up #1
- Learn Python Series (#5) - Handling Lists Part 1
- Learn Python Series (#6) - Handling Lists Part 2
- Learn Python Series (#7) - Handling Dictionaries
- Learn Python Series (#8) - Handling Tuples
- Learn Python Series (#9) - Using Import
- Learn Python Series (#10) - Matplotlib Part 1
- Learn Python Series (#11) - NumPy Part 1
- Learn Python Series (#12) - Handling Files
- Learn Python Series (#13) - Mini Project - Developing a Web Crawler Part 1
- Learn Python Series (#14) - Mini Project - Developing a Web Crawler Part 2
- Learn Python Series (#15) - Handling JSON
- Learn Python Series (#16) - Mini Project - Developing a Web Crawler Part 3
- Learn Python Series (#17) - Roundup #2 - Combining and analyzing any-to-any multi-currency historical data
- Learn Python Series (#18) - PyMongo Part 1
- Learn Python Series (#19) - PyMongo Part 2
- Learn Python Series (#20) - PyMongo Part 3
- Learn Python Series (#21) - Handling Dates and Time Part 1
- Learn Python Series (#22) - Handling Dates and Time Part 2
- Learn Python Series (#23) - Handling Regular Expressions Part 1
- Learn Python Series (#24) - Handling Regular Expressions Part 2
- Learn Python Series (#25) - Handling Regular Expressions Part 3
- Learn Python Series (#26) - pipenv & Visual Studio Code
- Learn Python Series (#27) - Handling Strings Part 3 (F-Strings)
- Learn Python Series (#28) - Using Pickle and Shelve
- Learn Python Series (#29) - Handling CSV
- Learn Python Series (#30) - Data Science Part 1 - Pandas
- Learn Python Series (#31) - Data Science Part 2 - Pandas
- Learn Python Series (#32) - Data Science Part 3 - Pandas
- Learn Python Series (#33) - Data Science Part 4 - Pandas
- Learn Python Series (#34) - Working with APIs in 2026: What's Changed
- Learn Python Series (#35) - Working with APIs Part 2: Beyond GET Requests
- Learn Python Series (#36) - Type Hints and Modern Python
- Learn Python Series (#37) - Virtual Environments and Dependency Management
- Learn Python Series (#38) - Testing Your Code Part 1
- Learn Python Series (#39) - Testing Your Code Part 2
- Learn Python Series (#40) - Asynchronous Python Part 1
- Learn Python Series (#41) - Asynchronous Python Part 2
- Learn Python Series (#42) - Building CLI Applications
- Learn Python Series (#43) - Mini Project - Crypto Price Tracker
- Learn Python Series (#44) - Context Managers & Decorators Deep Dive
- Learn Python Series (#45) - Metaclasses & Class Design Patterns
- Learn Python Series (#46) - Descriptors & Properties (this post)
GitHub Account
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:
- Data descriptors from the class (and its MRO chain)
- The instance's own
__dict__ - Non-data descriptors from the class (and its MRO chain)
__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__(viasetattr): 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 whyLazyPropertyworks - Methods are non-data descriptors:
function.__get__creates bound methods, explaining howselfworks @propertyis a data descriptor that delegates to getter/setter/deleter functions@staticmethodand@classmethodare 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
WeakKeyDictionaryprevents 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.