|$ curl https://forge-ai.dev/api/markdown?path=docs/python/properties
$cat docs/python-—-properties-&-descriptors.md
updated Recently·16 min read·published

Python — Properties & Descriptors

PythonAdvanced
Introduction

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

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.

property.py
Python
1class 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
19c = Circle(5)
20print(c.radius) # 5
21print(c.area) # 78.53975
22print(c.circumference) # 31.4159
23# c.area = 100 # AttributeError: can't set attribute
24# c.radius = 10 # AttributeError: can't set attribute

info

Name the backing attribute with a leading underscore (_radius) to avoid name clashes with the property. The property shadows self._radius in the public interface while keeping the actual storage separate.
Getter / Setter / Deleter

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.

getter_setter.py
Python
1class 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
33t = Temperature(100)
34print(t.celsius) # 100 (getter)
35t.celsius = 50 # setter (valid)
36print(t.fahrenheit) # 122.0
37t.fahrenheit = 212 # setter converts: celsius = 100
38print(t.celsius) # 100.0
39
40# t.celsius = -300 # ValueError: below absolute zero
41del t.celsius # "Deleting celsius — resetting to 0"
42print(t.celsius) # 0

best practice

Keep setters lightweight — validate input, maybe normalize (e.g. rounding), but avoid side effects like writing to a database. Callers expect obj.x = y to be fast and predictable.
Read-Only Properties

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.

readonly.py
Python
1import hashlib
2
3class 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
42u = User("Alice", "alice@example.com")
43print(u.created_at) # 1712345678.90
44print(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)
48u.email = "alice@newdomain.com"
49print(u.email_hash) # new hash reflecting updated email
Cached Properties

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.

cached.py
Python
1from functools import cached_property
2import time
3
4class 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
21dl = DataLoader("https://api.example.com/data")
22
23# First access — triggers the computation (2s delay)
24print(len(dl.data)) # "Fetching data ..." then 2
25
26# Second access — instant, uses cached value
27print(len(dl.data)) # no print, no delay
28
29# .data is stored in instance __dict__
30print(dl.__dict__) # {'data': [...], 'metadata': {...}}
31
32# Clear cache manually:
33del dl.data # removes from __dict__
34print(len(dl.data)) # "Fetching data ..." again (recomputed)
35
36# Thread-safe alternative — manual caching in __init__:
37class 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

cached_property is not thread-safe in cached_property's default mode — concurrent access may compute the value multiple times. Use a mutex or the manual @property + None-check pattern in threaded code.
The property() Function

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.

property_func.py
Python
1def get_name(self):
2 return self._name
3
4def set_name(self, value):
5 if not value.strip():
6 raise ValueError("Name cannot be empty")
7 self._name = value.strip()
8
9def del_name(self):
10 print("Deleting name — resetting")
11 self._name = ""
12
13class 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
20p = Person(" Alice ")
21print(p.name) # "Alice" (getter — strip in setter already ran)
22p.name = "Bob"
23# p.name = "" # ValueError: Name cannot be empty
24del p.name # "Deleting name — resetting"
25print(p.name) # ""
26
27# ─── Dynamic properties via property() ───
28def 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
40def positive(val):
41 return val > 0
42
43class 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
53prod = Product(19.99, 100)
54print(prod.price) # 19.99
55# prod.price = -5 # ValueError: Invalid value for price: -5

best practice

The @property decorator syntax is preferred for readability. Reserve property() for metaprogramming — factories, descriptors libraries, or when you need to attach properties from a base class at runtime.
Property Validation Patterns

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.

validation.py
Python
1import re
2from typing import Optional
3
4class 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
63p = Patient(" alice jones ", 30, "Alice@Example.COM", "123-45-6789")
64print(p.name) # "Alice Jones"
65print(p.email) # "alice@example.com"
66print(p.ssn) # "***-**-6789"
67# p.age = 200 # ValueError: Age 200 out of range [0, 150]

info

Calling setters inside __init__ (via self.attr = value) guarantees validation runs on construction too. If you assign to self._attr directly, you bypass all checks — reserve that for internal use only.
The Descriptor Protocol

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.

descriptors.py
Python
1# ─── Descriptor protocol signature ───
2# obj is the instance (None for class access)
3# owner is the owning class
4
5class 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
28class 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
40o = Order(10, 5.99)
41print(o.quantity) # 10 (triggers __get__)
42print(o.total) # 59.9
43# o.quantity = 0 # ValueError: quantity must be positive
44# o.price = "bad" # TypeError: price must be numeric
45del o.price # "Deleting price" — resets to 0.0
46print(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
53class NonDataDesc:
54 def __get__(self, obj, owner):
55 return "from descriptor"
56
57class 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
63class Demo:
64 non_data = NonDataDesc()
65 data_desc = DataDesc()
66
67d = Demo()
68print(d.non_data) # "from descriptor"
69d.__dict__["non_data"] = "from instance dict"
70print(d.non_data) # "from instance dict" (instance wins for non-data)
71
72print(d.data_desc) # "from data descriptor"
73d.__dict__["data_desc"] = "from instance dict"
74print(d.data_desc) # "from data descriptor" (descriptor wins for data)

best practice

Writing a custom descriptor is rarely needed — @property covers most managed-attribute use cases. Reach for descriptors when you need to reuse the same validation logic across many classes (libraries like SQLAlchemy and Django use them for field definitions).
Best Practices

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.

$Blueprint — Engineering Documentation·Section ID: PYTHON-PROP·Revision: 1.0