Course topics

By WebNest Studio

Python Tutorial

Properties and Descriptors

In Python you start with plain public attributes. If you later need validation, a computed value or a read-only field, you do not have to change every caller from obj.price to obj.get_price() — you turn the attribute into a property. Properties are built on a more general mechanism, descriptors, which also power methods, classmethod, staticmethod and ORM fields.

This lesson covers @property with setters and deleters, computed and cached properties, and writing reusable descriptors with __get__, __set__ and __set_name__.

@property, Setters and Deleters

Decorating a method with @property makes it accessible like an attribute. Add @name.setter to validate or transform assigned values and @name.deleter for deletion. A property without a setter is read-only. Store the real value in a "private" attribute such as self._price.

Computed and Cached Properties

Properties can compute values on the fly — full_name from first and last name, area from width and height — so derived data never goes stale. For expensive computations on objects that do not change, functools.cached_property computes the value once and stores it on the instance.

Descriptors

A descriptor is an object stored as a class attribute that defines __get__ (and optionally __set__, __delete__). Python calls these when the attribute is accessed on instances. __set_name__ tells the descriptor which attribute name it was assigned to. Descriptors let you write validation once — "positive number", "non-empty string" — and reuse it across many classes and fields; this is how Django and SQLAlchemy model fields work.

Examples

Validated attributes and computed properties

Python
class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height

    @property
    def width(self):
        return self._width

    @width.setter
    def width(self, value):
        if value <= 0:
            raise ValueError("width must be positive")
        self._width = value

    @property
    def height(self):
        return self._height

    @height.setter
    def height(self, value):
        if value <= 0:
            raise ValueError("height must be positive")
        self._height = value

    @property
    def area(self):                  # computed, read-only
        return self.width * self.height

r = Rectangle(3, 4)
print(r.area)
r.width = 10
print(r.area)
try:
    r.height = -1
except ValueError as e:
    print("ValueError:", e)
try:
    r.area = 5
except AttributeError as e:
    print("AttributeError:", e)
Output
12
40
ValueError: height must be positive
AttributeError: property 'area' of 'Rectangle' object has no setter

Deleters and cached_property

Python
from functools import cached_property

class User:
    def __init__(self, first, last):
        self.first, self.last = first, last
        self._email = None

    @property
    def full_name(self):
        return f"{self.first} {self.last}"

    @property
    def email(self):
        return self._email or "not set"

    @email.setter
    def email(self, value):
        self._email = value.strip().lower()

    @email.deleter
    def email(self):
        self._email = None

class Report:
    def __init__(self, rows):
        self.rows = rows

    @cached_property
    def total(self):
        print("computing total...")
        return sum(self.rows)

u = User("Asha", "Rao")
u.email = "  Asha@Webnest.IN "
print(u.full_name, u.email)
del u.email
print(u.email)

rep = Report([10, 20, 30])
print(rep.total, rep.total)
Output
Asha Rao asha@webnest.in
not set
computing total...
60 60

A reusable validating descriptor

Python
class Positive:
    def __set_name__(self, owner, name):
        self.private_name = "_" + name
        self.public_name = name

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return getattr(obj, self.private_name)

    def __set__(self, obj, value):
        if not isinstance(value, (int, float)) or value <= 0:
            raise ValueError(f"{self.public_name} must be a positive number, got {value!r}")
        setattr(obj, self.private_name, value)

class Product:
    price = Positive()
    stock = Positive()

    def __init__(self, name, price, stock):
        self.name, self.price, self.stock = name, price, stock

p = Product("Pen", 10, 100)
print(p.price, p.stock, vars(p))
for field, value in [("price", -5), ("stock", "many")]:
    try:
        setattr(p, field, value)
    except ValueError as e:
        print("ValueError:", e)
Output
10 100 {'name': 'Pen', '_price': 10, '_stock': 100}
ValueError: price must be a positive number, got -5
ValueError: stock must be a positive number, got 'many'

Common Mistakes

  • Storing the value under the same name as the property (self.width inside the width setter), causing infinite recursion.
  • Writing get_x/set_x methods everywhere instead of starting with plain attributes and switching to properties later.
  • Using cached_property on objects whose inputs change, leaving a stale cached value.
  • Putting slow work or side effects in properties that callers assume are cheap.

Key Points to Remember

  • @property turns methods into attribute-style access; add .setter and .deleter as needed.
  • Properties allow validation, read-only fields and computed values without changing callers.
  • cached_property computes once per instance.
  • Descriptors (__get__, __set__, __set_name__) make reusable attribute logic.

Practice the examples

Change an input, predict the result, then compare it with the output. Explain why the result changes.