Descriptors and Properties
What property is built from, the descriptor protocol underneath it, and using it deliberately for validation, laziness and computed attributes without turning a class into magic.
What property is built from, the descriptor protocol underneath it, and using it deliberately for validation, laziness and computed attributes without turning a class into magic.
@property turns a method into something that reads like an
attribute. It is presented as syntax, and it is not — it is an
ordinary class, written in a few lines of Python, implementing
three methods you could implement yourself.
Knowing that is worth more than knowing property, because the
same mechanism explains how methods get bound to instances, how
classmethod and staticmethod work, and how ORMs turn
user.email into a database column. By the end of this lesson
you will write descriptors, know where they store their data,
and know the point at which one is more machinery than the
problem deserves.
class Photo:
def __init__(self, width: int, height: int) -> None:
self._width = width
self._height = height
@property
def aspect_ratio(self) -> float:
return self._width / self._heightphoto.aspect_ratio # no parentheses - reads like a fieldThe value is computed each time and never stored, so it cannot go stale. That is the main reason to use one: a derived value that would otherwise need updating whenever its inputs change.
Adding a setter lets you validate on assignment:
@property
def width(self) -> int:
return self._width
@width.setter
def width(self, value: int) -> None:
if value <= 0:
raise ValueError(f"width must be positive, got {value}")
self._width = valuephoto.width = -5 # ValueError, at the assignmentThe important part is that callers write photo.width = 800.
The validation arrived without changing a single call site,
which is why you do not need getters and setters "just in case"
— you can add them later without breaking anyone. That is the
opposite of the convention in several other languages, and it is
why Python code has public attributes.
A descriptor is any object defining __get__, __set__ or
__delete__, used as a class attribute. Python's attribute
lookup checks for these and calls them.
Here is property, roughly, in enough detail to be honest:
class Property:
def __init__(self, fget=None, fset=None):
self.fget = fget
self.fset = fset
def __get__(self, instance, owner=None):
if instance is None:
return self # accessed on the class
if self.fget is None:
raise AttributeError("unreadable attribute")
return self.fget(instance)
def __set__(self, instance, value):
if self.fset is None:
raise AttributeError("can't set attribute")
self.fset(instance, value)
def setter(self, fset):
return Property(self.fget, fset)That is the whole trick. @property puts an instance of this
class in the class body; reading photo.width finds it and
calls __get__.
The instance is None branch is why Photo.width gives you the
property object rather than an error — accessed on the class,
there is no instance to compute from.
The reason to reach past property is reuse. Five fields
needing the same validation means five near-identical property
pairs; a descriptor is written once.
class Positive:
"""A numeric attribute that must be greater than zero."""
def __set_name__(self, owner: type, name: str) -> None:
self._name = name # the attribute's own name
def __get__(self, instance, owner=None):
if instance is None:
return self
return instance.__dict__[self._name]
def __set__(self, instance, value) -> None:
if value <= 0:
raise ValueError(
f"{self._name} must be positive, got {value!r}"
)
instance.__dict__[self._name] = valueclass Photo:
width = Positive()
height = Positive()
file_size = Positive()
def __init__(self, width, height, file_size):
self.width = width # goes through __set__
self.height = height
self.file_size = file_sizePhoto(800, 600, -1)
# ValueError: file_size must be positive, got -1Three fields, one implementation, and the error message names
the field — which comes from __set_name__, called by Python
when the class is created, telling each descriptor what it was
assigned to. Before that method existed you had to repeat the
name (width = Positive("width")), and you will still see that
in older code.
The question every descriptor has to answer, and the one that produces the classic bug.
Bad — storing the value on the descriptor.
class Positive:
def __set__(self, instance, value):
self._value = value # on the DESCRIPTOR
def __get__(self, instance, owner=None):
return self._valueGood — storing it on the instance.
class Positive:
def __set_name__(self, owner, name):
self._name = name
def __set__(self, instance, value):
instance.__dict__[self._name] = value
def __get__(self, instance, owner=None):
if instance is None:
return self
return instance.__dict__[self._name]There is one descriptor object per class attribute, shared
by every instance — it is created once, in the class body. So
the first version gives every Photo the same width: setting it
on one changes it for all of them, and nothing errors. It is the
mutable-class-attribute trap from the foundations course, in the
one place where the sharing is not visible in the code.
Storing in instance.__dict__ under the attribute's own name
works because a descriptor defining __set__ takes priority
over the instance dictionary during lookup — so the entry is
findable by you and invisible to normal attribute access.
That priority is a rule worth knowing precisely, because it explains behaviour that otherwise looks inconsistent.
Defines __set__ or __delete__.
Consulted before the instance __dict__, so nothing an
instance stores can get around it. A property is one of
these.
Defines only __get__.
Consulted after the instance __dict__, so assigning to the
instance shadows it. Ordinary methods are these.
Which gives the full lookup order:
Data descriptors on the type
They win over everything.
The instance __dict__
Non-data descriptors on the type
Class attributes
__getattr__, if nothing was found
So a property (data) cannot be shadowed by setting an instance
attribute, and a plain method (non-data) can:
photo.resize = lambda: None # shadows the method, no error
photo.width = 800 # goes through the propertyThis is also how functools.cached_property works: it is a
non-data descriptor, so on first access it computes the value
and writes it into instance.__dict__, and every later access
finds the instance entry at step 2 without the descriptor
running at all.
Descriptors are the mechanism behind ORMs, validation frameworks and typed settings libraries. They are almost always the wrong answer in application code.
Prefer, in this order:
A plain attribute
Most values need nothing at all.
A dataclass with __post_init__
Validation in one place, at construction, which the modelling lesson argued is where it belongs.
A property
For one computed or validated field.
A descriptor
Only when the same behaviour repeats across several fields or several classes, and you own both sides.
The costs are real: attribute access is no longer obvious to a
reader, type checkers need care to follow it, and debugging goes
through machinery in another file. A descriptor used three times
in one class earns its place; one used once is a property
written the long way.
PROPERTY
@property computed, never stale
@x.setter validate on assignment
callers still write obj.x = 5 - no call sites change
which is why Python has public attributes and no
defensive getters
WHAT IT IS
a descriptor: an object defining __get__/__set__/__delete__,
used as a CLASS attribute
property is ~20 lines of ordinary Python
functions are descriptors too - that is how self gets bound
WRITING ONE
def __set_name__(self, owner, name) Python tells you the name
def __get__(self, instance, owner=None)
if instance is None: return self accessed on the class
def __set__(self, instance, value)
STORAGE - the classic bug
ONE descriptor object per class attribute, shared by all
instances
storing on self -> every instance shares one value, silently
store in instance.__dict__[self._name]
LOOKUP ORDER
1 data descriptors (__set__/__delete__) <- beat the instance
2 instance __dict__
3 non-data descriptors (__get__ only)
4 class attributes
5 __getattr__
property cannot be shadowed; a method can
cached_property is non-data: it writes to __dict__ once,
then step 2 answers forever
WHEN
plain attribute > dataclass __post_init__ > property > descriptor
a descriptor earns its place when the behaviour REPEATS
TRAP
__slots__ means no instance __dict__ to write intoYou now know that property is not syntax, where a descriptor
must put its data, and the lookup order that decides which
attribute wins. That order is the thing to keep — it explains
cached_property, bound methods, and why some attributes can be
shadowed and others cannot.
Next is Metaclasses and Class Creation, which goes one level further out. Descriptors customise what happens when an attribute is accessed; metaclasses customise what happens when a class is created — and the lesson spends much of its length on the two simpler tools that solve most of what people reach for metaclasses to do.
Before you move on, write the Positive descriptor and use it
on two fields of one class. Then deliberately store the value on
the descriptor instead of the instance, create two objects, and
watch them share a value. That failure takes thirty seconds to
produce and is the single thing worth remembering from this
lesson.