Properties and Descriptors
Properties and Descriptors
A property is an attribute that runs a method when you read it (and optionally when you set it). The boring default is @property for a computed value or a checked assignment. A descriptor is the general protocol behind properties. Write a custom descriptor only when the same rule must live on several classes.
Mental model
t.table looks like data. If table is a property, Python calls a method instead of returning a dict entry. Callers still write t.table and t.table = 4. They do not write t.get_table().
@property turns a method into the getter. @table.setter registers the setter. Store the real value on self._table (leading underscore: “internal”). The getter returns it. The setter validates, then assigns _table. If the setter assigns self.table, it calls itself until the stack blows.
A descriptor is a class with __get__ and usually __set__. Putting an instance of it on a class body makes that name a managed attribute. property is a descriptor. You almost never need a second one.
Worked examples
Case 1: @property for a computed total
Save as order_total.py. total is not stored. It is derived from cents and tax_rate. There is no setter: callers cannot assign order.total = 0 by accident.
# order_total.py
class Order:
def __init__(self, cents, tax_rate=0.1):
self.cents = cents
self.tax_rate = tax_rate
@property
def total(self):
return round(self.cents * (1 + self.tax_rate))
def main():
order = Order(400)
print(order.total)
order.cents = 500
print(order.total)
try:
order.total = 0
except AttributeError as e:
print(type(e).__name__ + ":", e)
if __name__ == "__main__":
main()Run:
uv run python order_total.pyOutput:
440
550
AttributeError: property 'total' of 'Order' object has no setter
Computed fields stay properties. Do not cache them until you have measured a problem.
Case 2: A setter that validates
Save as ticket_table.py. Assigning table in __init__ goes through the setter, so Ticket(1, 0) fails the same way as a later assignment.
# ticket_table.py
class Ticket:
def __init__(self, id, table):
self.id = id
self.table = table
@property
def table(self):
return self._table
@table.setter
def table(self, n):
if n <= 0:
raise ValueError(f"table {n}: must be positive")
self._table = n
def main():
t = Ticket(7, 12)
print(t.table)
t.table = 4
print(t.table)
try:
t.table = 0
except ValueError as e:
print(type(e).__name__ + ":", e)
if __name__ == "__main__":
main()Run:
uv run python ticket_table.pyOutput:
12
4
ValueError: table 0: must be positive
One rule, two paths (__init__ and later). That is why the setter is worth it.
Case 3: A tiny descriptor
Save as positive.py. Positive is the same “must be > 0” rule, reusable on more than one name. __set_name__ records the attribute name so storage can be _table or _cents.
# positive.py
class Positive:
def __set_name__(self, owner, name):
self.private = "_" + name
def __get__(self, obj, owner):
if obj is None:
return self
return getattr(obj, self.private)
def __set__(self, obj, value):
if value <= 0:
raise ValueError(f"{self.private[1:]} must be positive")
setattr(obj, self.private, value)
class Ticket:
table = Positive()
cents = Positive()
def __init__(self, id, table, cents):
self.id = id
self.table = table
self.cents = cents
def main():
t = Ticket(7, 12, 400)
print(t.table, t.cents)
try:
t.cents = 0
except ValueError as e:
print(type(e).__name__ + ":", e)
if __name__ == "__main__":
main()Run:
uv run python positive.pyOutput:
12 400
ValueError: cents must be positive
Two fields, one rule. That is the moment a descriptor earns a file. For a single field, Case 2 is enough.
The trap
A setter that assigns the public name calls itself. RecursionError. The same bug happens if you write self.table = n inside table.setter instead of self._table = n.
Save as setter_loop.py:
# setter_loop.py
class Ticket:
def __init__(self, id, table):
self.id = id
self._table = table
@property
def table(self):
return self._table
@table.setter
def table(self, n):
if n <= 0:
raise ValueError(f"table {n}: must be positive")
self.table = n
def main():
t = Ticket(7, 12)
try:
t.table = 4
except RecursionError as e:
print(type(e).__name__ + ":", e)
if __name__ == "__main__":
main()Run:
uv run python setter_loop.pyOutput:
RecursionError: maximum recursion depth exceeded
__init__ assigns _table directly, so construction survives. The later t.table = 4 dies. Fix: self._table = n in the setter, and in __init__ prefer self.table = table so construction uses the same check (see ticket_table.py).
A second trap: writing Positive for one field on one class. Use @property. Save the descriptor for a repeated rule.
The boring rule
@propertyfor derived values. No setter unless callers must assign.@x.setterfor validation. Store onself._x.- Construction should go through the setter (
self.x = xin__init__) so bad values fail early. - A custom descriptor is for one rule on many attributes or many classes.
- Prefer a property over a custom descriptor. Prefer a function over a property if the name is clearly an action (
label(), not.labelas data). - Do not put business logic in
__get__that hits the network. Keep descriptors dull.
Try this
- In
order_total.py, add@property def tax(self)that returnsself.total - self.cents. Print it for 400 cents. - In
ticket_table.py, constructTicket(1, 0)and catchValueError. - In
positive.py, addid = Positive()and tryTicket(0, 12, 400). Read the error. - Fix
setter_loop.pysot.table = 4prints4. Keep the<= 0check.