Python — Properties & Descriptors
Properties and descriptors are Python's mechanism for managed attributes — they let you attach getter/setter/deleter logic to attribute access while preserving a clean obj.attr interface. The @property decorator is the most common way to define computed or validated attributes; the descriptor protocol (__get__, __set__, __delete__) is the lower-level foundation that powers @property itself, along with slots, classmethod, and staticmethod.
The @property decorator turns a method into a read-only attribute. It is the idiomatic way to replace a simple attribute with computed logic without breaking callers.
| 1 | class Circle: |
| 2 | def __init__(self, radius: float): |
| 3 | self._radius = radius |
| 4 | |
| 5 | @property |
| 6 | def radius(self) -> float: |
| 7 | """Get the radius.""" |
| 8 | return self._radius |
| 9 | |
| 10 | @property |
| 11 | def area(self) -> float: |
| 12 | """Computed from radius — no setter needed.""" |
| 13 | return 3.14159 * self._radius ** 2 |
| 14 | |
| 15 | @property |
| 16 | def circumference(self) -> float: |
| 17 | return 2 * 3.14159 * self._radius |
| 18 | |
| 19 | c = Circle(5) |
| 20 | print(c.radius) # 5 |
| 21 | print(c.area) # 78.53975 |
| 22 | print(c.circumference) # 31.4159 |
| 23 | # c.area = 100 # AttributeError: can't set attribute |
| 24 | # c.radius = 10 # AttributeError: can't set attribute |
info
A property can expose a getter, setter, and deleter. The method names must match — @property decorates the getter, @attr.setter the setter, and @attr.deleter the deleter.
| 1 | class Temperature: |
| 2 | def __init__(self, celsius: float = 0): |
| 3 | self._celsius = celsius |
| 4 | |
| 5 | @property |
| 6 | def celsius(self) -> float: |
| 7 | """Getter — return the stored value.""" |
| 8 | return self._celsius |
| 9 | |
| 10 | @celsius.setter |
| 11 | def celsius(self, value: float) -> None: |
| 12 | """Setter — with validation.""" |
| 13 | if value < -273.15: |
| 14 | raise ValueError(f"Temperature {value} is below absolute zero") |
| 15 | self._celsius = value |
| 16 | |
| 17 | @celsius.deleter |
| 18 | def celsius(self) -> None: |
| 19 | """Deleter — reset to default.""" |
| 20 | print("Deleting celsius — resetting to 0") |
| 21 | self._celsius = 0 |
| 22 | |
| 23 | @property |
| 24 | def fahrenheit(self) -> float: |
| 25 | """Read-only computed property.""" |
| 26 | return self._celsius * 9 / 5 + 32 |
| 27 | |
| 28 | @fahrenheit.setter |
| 29 | def fahrenheit(self, value: float) -> None: |
| 30 | """Setter converts from Fahrenheit to Celsius.""" |
| 31 | self.celsius = (value - 32) * 5 / 9 |
| 32 | |
| 33 | t = Temperature(100) |
| 34 | print(t.celsius) # 100 (getter) |
| 35 | t.celsius = 50 # setter (valid) |
| 36 | print(t.fahrenheit) # 122.0 |
| 37 | t.fahrenheit = 212 # setter converts: celsius = 100 |
| 38 | print(t.celsius) # 100.0 |
| 39 | |
| 40 | # t.celsius = -300 # ValueError: below absolute zero |
| 41 | del t.celsius # "Deleting celsius — resetting to 0" |
| 42 | print(t.celsius) # 0 |
best practice
Omit the setter to create a read-only property. Attempting assignment raises AttributeError. This is the most common property pattern — expose a computed value while preventing mutation.
| 1 | import hashlib |
| 2 | |
| 3 | class User: |
| 4 | def __init__(self, name: str, email: str): |
| 5 | self._name = name |
| 6 | self._email = email |
| 7 | self._created_at: float = __import__("time").time() |
| 8 | |
| 9 | @property |
| 10 | def name(self) -> str: |
| 11 | return self._name |
| 12 | |
| 13 | @property |
| 14 | def email(self) -> str: |
| 15 | return self._email |
| 16 | |
| 17 | @email.setter |
| 18 | def email(self, value: str) -> None: |
| 19 | if "@" not in value: |
| 20 | raise ValueError("Invalid email") |
| 21 | self._email = value |
| 22 | |
| 23 | @property |
| 24 | def created_at(self) -> float: |
| 25 | """Timestamp is immutable after construction.""" |
| 26 | return self._created_at |
| 27 | |
| 28 | @property |
| 29 | def email_hash(self) -> str: |
| 30 | """Computed — always reflects current email.""" |
| 31 | return hashlib.sha256(self._email.encode()).hexdigest() |
| 32 | |
| 33 | @property |
| 34 | def profile(self) -> dict: |
| 35 | """Computed aggregated view.""" |
| 36 | return { |
| 37 | "name": self._name, |
| 38 | "email": self._email, |
| 39 | "created_at": self._created_at, |
| 40 | } |
| 41 | |
| 42 | u = User("Alice", "alice@example.com") |
| 43 | print(u.created_at) # 1712345678.90 |
| 44 | print(u.email_hash) # 8d969... (SHA-256 hex) |
| 45 | # u.created_at = 0 # AttributeError: can't set attribute |
| 46 | |
| 47 | # Email can be updated (has setter) |
| 48 | u.email = "alice@newdomain.com" |
| 49 | print(u.email_hash) # new hash reflecting updated email |
Python 3.8+ provides @functools.cached_property — a decorator that computes the value once, then caches it in the instance dict. Unlike @property, the result is stored and subsequent lookups skip recomputation.
| 1 | from functools import cached_property |
| 2 | import time |
| 3 | |
| 4 | class DataLoader: |
| 5 | def __init__(self, url: str): |
| 6 | self.url = url |
| 7 | |
| 8 | @cached_property |
| 9 | def data(self) -> list[dict]: |
| 10 | """Expensive fetch — computed once, cached forever.""" |
| 11 | print(f"Fetching data from {self.url} ...") |
| 12 | time.sleep(2) # simulate network call |
| 13 | return [{"id": 1, "value": "a"}, {"id": 2, "value": "b"}] |
| 14 | |
| 15 | @cached_property |
| 16 | def metadata(self) -> dict: |
| 17 | print("Loading metadata ...") |
| 18 | time.sleep(1) |
| 19 | return {"version": "1.0", "rows": 2} |
| 20 | |
| 21 | dl = DataLoader("https://api.example.com/data") |
| 22 | |
| 23 | # First access — triggers the computation (2s delay) |
| 24 | print(len(dl.data)) # "Fetching data ..." then 2 |
| 25 | |
| 26 | # Second access — instant, uses cached value |
| 27 | print(len(dl.data)) # no print, no delay |
| 28 | |
| 29 | # .data is stored in instance __dict__ |
| 30 | print(dl.__dict__) # {'data': [...], 'metadata': {...}} |
| 31 | |
| 32 | # Clear cache manually: |
| 33 | del dl.data # removes from __dict__ |
| 34 | print(len(dl.data)) # "Fetching data ..." again (recomputed) |
| 35 | |
| 36 | # Thread-safe alternative — manual caching in __init__: |
| 37 | class ThreadSafeLoader: |
| 38 | def __init__(self, url: str): |
| 39 | self.url = url |
| 40 | self._data: list[dict] | None = None |
| 41 | |
| 42 | @property |
| 43 | def data(self) -> list[dict]: |
| 44 | if self._data is None: |
| 45 | self._data = self._fetch() |
| 46 | return self._data |
| 47 | |
| 48 | def _fetch(self) -> list[dict]: |
| 49 | print(f"Thread-safe fetch from {self.url}") |
| 50 | return [{"id": 1}] |
warning
The property() built-in is the class-based equivalent of the @property decorator. It accepts fget, fset, fdel, and doc arguments. Use it when you need to define properties dynamically or prefer the explicit style.
| 1 | def get_name(self): |
| 2 | return self._name |
| 3 | |
| 4 | def set_name(self, value): |
| 5 | if not value.strip(): |
| 6 | raise ValueError("Name cannot be empty") |
| 7 | self._name = value.strip() |
| 8 | |
| 9 | def del_name(self): |
| 10 | print("Deleting name — resetting") |
| 11 | self._name = "" |
| 12 | |
| 13 | class Person: |
| 14 | def __init__(self, name: str): |
| 15 | self._name = name |
| 16 | |
| 17 | # Equivalent to @property / @name.setter / @name.deleter |
| 18 | name = property(get_name, set_name, del_name, "The person's name") |
| 19 | |
| 20 | p = Person(" Alice ") |
| 21 | print(p.name) # "Alice" (getter — strip in setter already ran) |
| 22 | p.name = "Bob" |
| 23 | # p.name = "" # ValueError: Name cannot be empty |
| 24 | del p.name # "Deleting name — resetting" |
| 25 | print(p.name) # "" |
| 26 | |
| 27 | # ─── Dynamic properties via property() ─── |
| 28 | def make_property(attr_name: str, validator=None): |
| 29 | """Factory that creates property objects at runtime.""" |
| 30 | def getter(self): |
| 31 | return getattr(self, f"_{attr_name}") |
| 32 | |
| 33 | def setter(self, value): |
| 34 | if validator and not validator(value): |
| 35 | raise ValueError(f"Invalid value for {attr_name}: {value!r}") |
| 36 | setattr(self, f"_{attr_name}", value) |
| 37 | |
| 38 | return property(getter, setter) |
| 39 | |
| 40 | def positive(val): |
| 41 | return val > 0 |
| 42 | |
| 43 | class Product: |
| 44 | def __init__(self, price: float, quantity: int): |
| 45 | self._price = 0.0 |
| 46 | self._quantity = 0 |
| 47 | self.price = price # uses dynamic setter |
| 48 | self.quantity = quantity |
| 49 | |
| 50 | price = make_property("price", validator=positive) |
| 51 | quantity = make_property("quantity", validator=positive) |
| 52 | |
| 53 | prod = Product(19.99, 100) |
| 54 | print(prod.price) # 19.99 |
| 55 | # prod.price = -5 # ValueError: Invalid value for price: -5 |
best practice
Properties excel at enforcing invariants at the attribute boundary. Common patterns include type coercion, range checks, and normalization. The setter is the natural place to validate — fail fast before the object enters an invalid state.
| 1 | import re |
| 2 | from typing import Optional |
| 3 | |
| 4 | class Patient: |
| 5 | def __init__(self, name: str, age: int, email: str, ssn: str): |
| 6 | # Direct setter calls to validate during __init__ |
| 7 | self.name = name |
| 8 | self.age = age |
| 9 | self.email = email |
| 10 | self.ssn = ssn |
| 11 | |
| 12 | # ── Name: strip, capitalize ── |
| 13 | @property |
| 14 | def name(self) -> str: |
| 15 | return self._name |
| 16 | |
| 17 | @name.setter |
| 18 | def name(self, value: str) -> None: |
| 19 | stripped = value.strip() |
| 20 | if not stripped: |
| 21 | raise ValueError("Name cannot be empty") |
| 22 | self._name = stripped.title() |
| 23 | |
| 24 | # ── Age: range check ── |
| 25 | @property |
| 26 | def age(self) -> int: |
| 27 | return self._age |
| 28 | |
| 29 | @age.setter |
| 30 | def age(self, value: int) -> None: |
| 31 | if not isinstance(value, int): |
| 32 | raise TypeError(f"Age must be int, got {type(value).__name__}") |
| 33 | if not 0 <= value <= 150: |
| 34 | raise ValueError(f"Age {value} out of range [0, 150]") |
| 35 | self._age = value |
| 36 | |
| 37 | # ── Email: format validation ── |
| 38 | _EMAIL_RE = re.compile(r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+.[a-zA-Z0-9-.]+$") |
| 39 | |
| 40 | @property |
| 41 | def email(self) -> str: |
| 42 | return self._email |
| 43 | |
| 44 | @email.setter |
| 45 | def email(self, value: str) -> None: |
| 46 | value = value.strip().lower() |
| 47 | if not self._EMAIL_RE.match(value): |
| 48 | raise ValueError(f"Invalid email format: {value!r}") |
| 49 | self._email = value |
| 50 | |
| 51 | # ── SSN: format + mask ── |
| 52 | @property |
| 53 | def ssn(self) -> str: |
| 54 | return "***-**-" + self._ssn[-4:] |
| 55 | |
| 56 | @ssn.setter |
| 57 | def ssn(self, value: str) -> None: |
| 58 | digits = re.sub(r"D", "", value) |
| 59 | if len(digits) != 9: |
| 60 | raise ValueError("SSN must have exactly 9 digits") |
| 61 | self._ssn = digits |
| 62 | |
| 63 | p = Patient(" alice jones ", 30, "Alice@Example.COM", "123-45-6789") |
| 64 | print(p.name) # "Alice Jones" |
| 65 | print(p.email) # "alice@example.com" |
| 66 | print(p.ssn) # "***-**-6789" |
| 67 | # p.age = 200 # ValueError: Age 200 out of range [0, 150] |
info
A descriptor is any object that defines __get__, __set__, or __delete__. Properties are implemented as descriptors under the hood. Descriptors are the mechanism behind @classmethod, @staticmethod, __slots__, and even bound-method resolution.
| 1 | # ─── Descriptor protocol signature ─── |
| 2 | # obj is the instance (None for class access) |
| 3 | # owner is the owning class |
| 4 | |
| 5 | class PositiveNumber: |
| 6 | """Descriptor that validates values are positive numbers.""" |
| 7 | |
| 8 | def __init__(self, attr: str): |
| 9 | self.attr = f"_{attr}" # storage key in instance __dict__ |
| 10 | |
| 11 | def __get__(self, obj, owner): |
| 12 | if obj is None: |
| 13 | return self # class access returns the descriptor |
| 14 | return getattr(obj, self.attr) |
| 15 | |
| 16 | def __set__(self, obj, value): |
| 17 | if not isinstance(value, (int, float)): |
| 18 | raise TypeError(f"{self.attr[1:]} must be numeric") |
| 19 | if value <= 0: |
| 20 | raise ValueError(f"{self.attr[1:]} must be positive") |
| 21 | setattr(obj, self.attr, value) |
| 22 | |
| 23 | def __delete__(self, obj): |
| 24 | print(f"Deleting {self.attr[1:]}") |
| 25 | setattr(obj, self.attr, 0.0) |
| 26 | |
| 27 | |
| 28 | class Order: |
| 29 | quantity = PositiveNumber("quantity") # descriptor instance |
| 30 | price = PositiveNumber("price") # descriptor instance |
| 31 | |
| 32 | def __init__(self, quantity: float, price: float): |
| 33 | self.quantity = quantity # triggers PositiveNumber.__set__ |
| 34 | self.price = price |
| 35 | |
| 36 | @property |
| 37 | def total(self) -> float: |
| 38 | return self.quantity * self.price |
| 39 | |
| 40 | o = Order(10, 5.99) |
| 41 | print(o.quantity) # 10 (triggers __get__) |
| 42 | print(o.total) # 59.9 |
| 43 | # o.quantity = 0 # ValueError: quantity must be positive |
| 44 | # o.price = "bad" # TypeError: price must be numeric |
| 45 | del o.price # "Deleting price" — resets to 0.0 |
| 46 | print(o.price) # 0.0 |
| 47 | |
| 48 | # ─── Non-data vs data descriptors ─── |
| 49 | # Data descriptor: defines __set__ or __delete__ |
| 50 | # Non-data descriptor: defines only __get__ |
| 51 | # Data descriptors shadow instance __dict__; non-data descriptors do not. |
| 52 | |
| 53 | class NonDataDesc: |
| 54 | def __get__(self, obj, owner): |
| 55 | return "from descriptor" |
| 56 | |
| 57 | class DataDesc: |
| 58 | def __get__(self, obj, owner): |
| 59 | return "from data descriptor" |
| 60 | def __set__(self, obj, value): |
| 61 | print(f"Setting to {value!r}") |
| 62 | |
| 63 | class Demo: |
| 64 | non_data = NonDataDesc() |
| 65 | data_desc = DataDesc() |
| 66 | |
| 67 | d = Demo() |
| 68 | print(d.non_data) # "from descriptor" |
| 69 | d.__dict__["non_data"] = "from instance dict" |
| 70 | print(d.non_data) # "from instance dict" (instance wins for non-data) |
| 71 | |
| 72 | print(d.data_desc) # "from data descriptor" |
| 73 | d.__dict__["data_desc"] = "from instance dict" |
| 74 | print(d.data_desc) # "from data descriptor" (descriptor wins for data) |
best practice
1. Start simple — use a public attribute first. Promote to @property only when you need validation, computation, or a read-only view. The attribute-access syntax never changes.
2. Keep properties cheap — getters should not do I/O, network calls, or expensive computation. If they must, use @cached_property or an explicit cache layer.
3. Use setters for validation, not for triggering side effects. If setting an attribute should notify observers, emit a signal or push to a message bus — don't hide side effects behind obj.x = y.
4. Properties are not a security boundary — the backing _attr is always accessible via obj.__dict__ or direct access in sibling methods.
5. Document properties with docstrings — they show up in help(obj) and IDE tooltips. The getter's docstring becomes the property's docstring.
6. Prefer @property syntax over property() function — it is more readable and keeps getter/setter/deleter colocated.
7. Use descriptors sparingly — they make code harder to trace. Reserve them for frameworks or cross-cutting validation where @property would require duplication.
8. Remember the cached_property trade-off: speed on subsequent access, but stale data until cache is invalidated. Clear with del obj.prop or a dedicated refresh method.