Skip to content
  • Properties & Descriptors · Python

Properties & Descriptors

LearnViaNotes @learnvianotes· 6 min read

@property turns method calls into plain attribute access. Underneath it sits the descriptor protocol — the same mechanism that powers methods, classmethod, staticmethod, __slots__ and cached_property.

property recap: getter, setter, deleter

Start with plain public attributes. When you later need validation or a computed value, swap in a property — callers keep writing obj.attr and nothing breaks. That is why Python code has no getX()/setX() boilerplate.

Python
class Temperature:
    def __init__(self, celsius):
        self.celsius = celsius            # goes through the setter → validated from the start

    @property
    def celsius(self):                    # getter
        return self._celsius

    @celsius.setter
    def celsius(self, value):             # setter: validation lives here
        if value < -273.15:
            raise ValueError("below absolute zero")
        self._celsius = value

    @celsius.deleter
    def celsius(self):                    # deleter: runs on `del t.celsius`
        del self._celsius

    @property
    def fahrenheit(self):                 # computed and read-only (no setter)
        return self._celsius * 9 / 5 + 32

t = Temperature(100)
print(t.celsius, t.fahrenheit)            # 100 212.0
t.celsius = 0
print(t.fahrenheit)                       # 32.0
try:
    t.fahrenheit = 50
except AttributeError as e:
    print(type(e).__name__)               # AttributeError
del t.celsius
print(hasattr(t, "_celsius"))             # False
PatternHow
Read-only attribute@property with no setter — assignment raises AttributeError
Validated attributeSetter checks the value, stores it in self._name
Computed attributeGetter derives it from other fields — never goes stale
Lazy + cached@functools.cached_property — computed once, then stored

How obj.attr is looked up

KindDefinesPriority vs the instance __dict__Examples
Data descriptor__get__ + __set__ (or __delete__)Wins over obj.__dict__property, __slots__ members
Non-data descriptoronly __get__Loses to obj.__dict__functions (methods), classmethod, staticmethod, cached_property
  1. Data descriptor on the class?

    Search type(obj).__mro__. If the attribute found there is a data descriptor, call its __get__ and stop.

  2. Instance dictionary

    Otherwise, if the name is in obj.__dict__, return that value.

  3. Non-data descriptor or class attribute

    Otherwise use what was found on the class: call __get__ if it has one, else return it as is.

  4. __getattr__ fallback

    Still nothing? Call __getattr__(name) if the class defines it, else raise AttributeError.

Python
class Demo:
    @property
    def p(self):
        return "property"
    def m(self):
        return "method"

d = Demo()
d.__dict__["p"] = "instance value"       # sneak values straight into the instance dict
d.__dict__["m"] = lambda: "instance value"
print(d.p)       # property        ← data descriptor beats the instance dict
print(d.m())     # instance value  ← the instance dict beats a non-data descriptor

Writing a reusable descriptor

A property is tied to one attribute of one class. When many attributes need the same rule, write the rule once as a descriptor class and reuse it.

Python
class Positive:
    """A validated numeric field, reusable across classes."""
    def __set_name__(self, owner, name):      # called when the owning class is created
        self.name = name
        self.private = "_" + name

    def __get__(self, obj, objtype=None):
        if obj is None:                        # accessed on the class, not an instance
            return self
        return getattr(obj, self.private)

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

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

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

p = Product("pen", 20, 3)
print(p.price * p.qty)            # 60
try:
    p.qty = -1
except ValueError as e:
    print(e)                      # qty must be a positive number, got -1
print(Product.price.name)         # price
print(sorted(vars(p)))            # ['_price', '_qty', 'name']

Methods are descriptors too

A function in a class body is a non-data descriptor. obj.method calls function.__get__(obj, type(obj)), which returns a bound method with obj pre-filled as self.

Python
class Greeter:
    def hello(self, name):
        return f"hi {name}"

g = Greeter()
f = Greeter.__dict__["hello"]           # the plain function stored in the class
bound = f.__get__(g, Greeter)           # exactly what `g.hello` does
print(bound("asha"))                    # hi asha
print(bound.__self__ is g)              # True
print(type(g.hello).__name__, type(Greeter.hello).__name__)   # method function
print(hasattr(property, "__set__"), hasattr(f, "__set__"))    # True False
Built-inDescriptor kind__get__ returns
functionnon-dataa bound method (instance) or the function (class)
classmethodnon-dataa method bound to the class
staticmethodnon-datathe underlying function, unbound
propertydatathe getter's result
__slots__ entrydata (member_descriptor)the value in the object's fixed slot

__getattr__, __getattribute__ & __setattr__

Python
class Config:
    def __init__(self, **values):
        object.__setattr__(self, "_values", values)   # bypass our own __setattr__

    def __getattr__(self, name):          # only called when normal lookup FAILS
        try:
            return self._values[name]
        except KeyError:
            raise AttributeError(name) from None

    def __setattr__(self, name, value):   # called for EVERY assignment
        raise AttributeError(f"Config is read-only ({name})")

c = Config(debug=True, level=3)
print(c.debug, c.level)                   # True 3
print(getattr(c, "missing", "n/a"))       # n/a
try:
    c.level = 5
except AttributeError as e:
    print(e)                              # Config is read-only (level)
HookRunsTypical use
__getattr__Only when the attribute is not found normallyProxies, dynamic attributes, lazy loading
__getattribute__On every attribute readRarely — access logging, very special proxies
__setattr__ / __delattr__On every assignment / deletionImmutability, change tracking

cached_property

Python
from functools import cached_property

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

    @cached_property
    def total(self):                 # expensive work, done once per instance
        self.calls += 1
        return sum(self.rows)

r = Report([1, 2, 3])
print(r.total, r.total, r.calls)     # 6 6 1
print("total" in r.__dict__)         # True  ← the result now lives in the instance dict
del r.total                          # invalidate the cache
print(r.total, r.calls)              # 6 2

Interview angle

What is a descriptor?

Data vs non-data descriptor?

__getattr__ vs __getattribute__?

When would you write a descriptor instead of a property?

Free for students and teachers

Write notes like this one

Cards for every idea, quizzes and flashcards, read aloud — on the web and your phone.

Something wrong with this note? Open it to report