Properties and Descriptors

Updated

September 8, 2026

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.py

Output:

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.py

Output:

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.py

Output:

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.py

Output:

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

  • @property for derived values. No setter unless callers must assign.
  • @x.setter for validation. Store on self._x.
  • Construction should go through the setter (self.x = x in __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 .label as data).
  • Do not put business logic in __get__ that hits the network. Keep descriptors dull.

Try this

  1. In order_total.py, add @property def tax(self) that returns self.total - self.cents. Print it for 400 cents.
  2. In ticket_table.py, construct Ticket(1, 0) and catch ValueError.
  3. In positive.py, add id = Positive() and try Ticket(0, 12, 400). Read the error.
  4. Fix setter_loop.py so t.table = 4 prints 4. Keep the <= 0 check.