Descriptors & a peek at metaclasses
Customize attribute access with a descriptor and understand how classes are created.
Attribute access you can't route around
When a rule like "a balance is never negative" lives in one setter, someone eventually assigns the field from another code path and skips the check. A descriptor moves that rule off the value and onto the attribute itself, so every read and write of an account's balance goes through the same code no matter who touches it. This is how ORMs, typed config, and form fields validate assignments in real systems. It is also a favorite interview topic because it reveals whether you understand Python's attribute machinery instead of just its syntax.
The mental model
A descriptor is any object that defines __get__, __set__, or __delete__ and is stored as a class attribute. When you write acct.balance, Python does not just look in acct.__dict__. It finds balance on the class, sees it is a descriptor, and calls type(acct).__dict__['balance'].__get__(acct, Account). Assignment calls __set__ the same way.
One detail drives everything: the descriptor object is created once, when the class is defined, and shared by every instance. So the per-instance value must be stored on the instance, not on the descriptor. In the demo, __set_name__ runs at class-creation time and records storage_name = "_balance", then __set__ stashes the value with setattr(instance, "_balance", value) and __get__ reads it back. Account(100).balance returns 100; Account(0) is fine; Account(-5) raises ValueError and cannot be bypassed.
Pitfalls
- Storing state on the descriptor. Writing
self.value = valueinside__set__looks natural but shares one slot across all instances:
class Broken:
def __get__(self, instance, owner): return self.value
def __set__(self, instance, value): self.value = value # shared!
class C: x = Broken()
a, b = C(), C()
a.x = 1
b.x = 2
print(a.x) # 2 (b clobbered a)
The fix is exactly what the demo does: store on instance under a separate name.
-
Same storage name as the attribute. If
storage_namewere"balance"instead of"_balance", thensetattr(instance, "balance", value)re-triggers__set__forever and you get aRecursionError. The_prefix is what breaks the loop. -
Descriptor as an instance attribute.
balance = Positive()must sit in the class body. Assign it inside__init__and Python never invokes the protocol; it is just a normal attribute.
Interview nuance: descriptors come in two flavors and the difference decides precedence. A data descriptor defines __set__ or __delete__ and wins over the instance __dict__. A non-data descriptor defines only __get__ and loses to it. That is why @property (data) cannot be shadowed by an instance attribute, while functools.cached_property (non-data) writes its result into instance.__dict__ on first access and is then read straight from the dict on later calls, skipping recomputation.
The peek at metaclasses
A metaclass is "the class of a class". The default is type, and class Account: ... is roughly Account = type("Account", (), namespace). A custom metaclass hooks class creation to register, validate, or inject methods:
class Meta(type):
def __new__(mcls, name, bases, namespace):
return super().__new__(mcls, name, bases, namespace)
class Thing(metaclass=Meta): ...
You will rarely write one. abc.ABCMeta, enum.Enum, and Django models use them under the hood. Note that @dataclass is a class decorator, not a metaclass, so type(SomeDataclass) is type. Knowing classes are objects built by type is what dissolves the "magic".
class Positive:
def __set_name__(self, owner, name):
self.storage_name = "_" + name
def __get__(self, instance, owner):
return getattr(instance, self.storage_name)
def __set__(self, instance, value):
if value < 0:
raise ValueError("must be non-negative")
setattr(instance, self.storage_name, value)
class Account:
balance = Positive()
def __init__(self, balance):
self.balance = balance
print(Account(100).balance) # 100Apply
Your turn
The task this lesson builds to.
Warm-up (one file): implement the Positive descriptor so Account stores and returns a
balance through it (raising ValueError on negatives). The run driver builds an Account and
returns its balance.
run(100) is 100; run(0) is 0.
3 hints and 3 automated checks are waiting in the workspace.
Practice
Make it stick
A second problem on the same idea, so it survives past today.
Implement the Positive descriptor in models/fields.py: __get__ returns the stored value,
__set__ raises ValueError for negatives and otherwise stores the value (the storage name comes
from __set_name__). Account uses it for balance. Some tests are hidden.
3 hints and 2 automated checks are waiting in the workspace.