Properties and Descriptors
A property turns a method pair into attribute syntax: obj.celsius reads, obj.celsius = x writes, and the methods in between can validate, compute, cache, or convert. It is Python's answer to the encapsulation question from chapter 6 - when an attribute needs rules on access, the property owns them while keeping the clean obj.x interface. Under the hood, properties are the first concrete example of the descriptor protocol, the mechanism that also powers methods, classmethods, and staticmethods - understanding it once explains all of them.
The Basic Property
class Temperature:
def __init__(self, celsius=0.0):
self.celsius = celsius # the setter runs on this assignment
@property
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError("below absolute zero")
self._celsius = float(value)
@property
def fahrenheit(self):
# read-only derived value: a property with no setter
return self._celsius * 9 / 5 + 32
t = Temperature(25)
print(t.fahrenheit) # 77.0 - computed on read, stored nowhere
t.celsius = 100
print(t.celsius) # 100.0
try:
t.celsius = -300
except ValueError as e:
print(e) # below absolute zero
try:
t.fahrenheit = 212 # no setter defined
except AttributeError as e:
print(e) # can't set attribute
The structure: a getter (@property), an optional setter (@name.setter_celsius), because a setter that assigns to its own name would recurse. Two flavors appear above: a validating property (celsius, which checks and stores) and a computed property (fahrenheit, which derives on every read and has no setter, so assignment raises AttributeError). The computed kind is the honest answer to "should this be a method or an attribute?" when the value is cheap to derive and attribute syntax reads better - t.fahrenheit over t.get_fahrenheit().
Setters, Deleters, and the Full Protocol
class Cache:
def __init__(self):
self._value = None
@property
def value(self):
return self._value
@value.setter
def value(self, v):
self._value = v
@value.deleter
def value(self):
# del obj.value triggers this instead of vanishing silently
self._value = None
print("cleared")
c = Cache()
c.value = 42
print(c.value) # 42
del c.value # 'cleared' - the deleter ran
The third piece of the protocol is @name.deleter, invoked by del obj.name. Most properties define only the getter and setter; the deleter appears when "deleting" the attribute means doing something (invalidating a cache, closing a resource). The complete descriptor protocol has three hooks - __get__, __set__, __delete__ - and property is a ready-made descriptor built from exactly those three. Knowing the mapping is the shortcut: when property is not enough (shared state across instances, type-coercing attributes, cached descriptors), you write the __get__/__set__ pair yourself.
The Descriptor Protocol: What Methods Really Are
class Logged:
"""A descriptor: defines __get__ and __set__, so it manages
the attribute it is assigned to on the class."""
def __set_name__(self, owner, name):
self.name = name
def __get__(self, instance, owner):
if instance is None:
return self
return instance.__dict__.get(self.name, "empty")
def __set__(self, instance, value):
print(f"{self.name} set to {value!r}")
instance.__dict__[self.name] = value
class Record:
status = Logged()
r = Record()
r.status = "active" # 'status set to active' - the descriptor's __set__
print(r.status) # 'active' - the descriptor's __get__
A descriptor is any class with __get__ (and optionally __set__/__delete__) that is assigned as a class attribute. When you access the attribute on an instance, Python calls the descriptor's __get__ instead of returning a plain value - with the instance passed in. This is exactly what property does, and it is also what makes methods work: a function is a descriptor, and its __get__ is what binds self to produce the bound method (chapter 5's binding, revealed). __set_name__ (shown above) lets a descriptor learn the name it was assigned - the modern replacement for passing the name through __init__. Descriptors are the load-bearing wall of Python OOP: properties, methods, classmethods, staticmethods, and dataclass fields are all applications of the same three-hook protocol.
The Evolution Path: Attribute to Property
# Step 1: a plain attribute (the start)
class Account:
def __init__(self, owner):
self.owner = owner
self.balance = 0.0
# Step 2: a rule appears - balance must not go negative.
# Convert to a property; the public name is UNCHANGED, so callers are untouched.
class Account:
def __init__(self, owner):
self.owner = owner
self.balance = 0.0 # setter validates from day one
@property
def balance(self):
return self._balance
@balance.setter
def balance(self, value):
if value < 0:
raise ValueError("balance cannot be negative")
self._balance = value
The design path is the lesson: start with a plain attribute, and when a real rule appears, promote it to a property without renaming. Callers still write acct.balance = x; the behavior now lives in the setter, where it is testable. This avoids both failure modes - pre-emptively wrapping every attribute in a property (noise, before any rule exists) and discovering a rule after the attribute is public (a breaking rename). The property is the migration path that keeps the interface stable while the implementation gains teeth.
Summary
Use @property with an optional @name.setter (and rare @name.deleter) to put validation and computation behind attribute syntax, keeping the backing store in a private _name to avoid recursion. Computed properties (no setter) are the right answer for cheaply-derived values that read better as attributes than methods. Under the hood, a property is a descriptor - an object with __get__/__set__/__delete__ assigned as a class attribute - which is the same protocol that binds self to methods and powers classmethod/staticmethod; write your own descriptor when you need shared state, coercion, or caching that property does not express. And follow the evolution path: plain attribute until a rule appears, then promote in place so the public name never changes.