Properties & Descriptors

0/5 in this phase0/54 across the roadmap

📖 Concept

Properties provide a Pythonic way to add getters, setters, and deleters to attributes while maintaining the simple attribute access syntax. Instead of writing get_name() and set_name() methods (Java style), Python uses @property to make method calls look like attribute access.

The @property decorator:

class Circle:
    def __init__(self, radius):
        self._radius = radius

    @property
    def radius(self):           # Getter
        return self._radius

    @radius.setter
    def radius(self, value):    # Setter
        if value < 0:
            raise ValueError("Radius cannot be negative")
        self._radius = value

When to use properties:

  • Validation — enforce constraints when setting values
  • Computed attributes — calculate values on access (area from radius)
  • Lazy loading — defer expensive computation until first access
  • Backward compatibility — convert a public attribute to a property without changing the API

Descriptors are the mechanism behind properties, methods, classmethods, and staticmethods. A descriptor is any object that defines __get__, __set__, or __delete__ methods.

Descriptor protocol:

  • __get__(self, obj, type) — called on attribute access
  • __set__(self, obj, value) — called on attribute assignment
  • __delete__(self, obj) — called on attribute deletion

Data descriptors (define __set__ or __delete__) take precedence over instance dictionaries. Non-data descriptors (only __get__) can be overridden by instance attributes.

💻 Code Example

codeTap to expand ⛶
1# ============================================================
2# @property basics
3# ============================================================
4class Circle:
5 def __init__(self, radius):
6 self.radius = radius # Calls the setter!
7
8 @property
9 def radius(self):
10 """The radius of the circle."""
11 return self._radius
12
13 @radius.setter
14 def radius(self, value):
15 if not isinstance(value, (int, float)):
16 raise TypeError("Radius must be a number")
17 if value < 0:
18 raise ValueError("Radius cannot be negative")
19 self._radius = value
20
21 @property
22 def area(self):
23 """Computed property — calculated on access."""
24 import math
25 return math.pi * self._radius ** 2
26
27 @property
28 def diameter(self):
29 return self._radius * 2
30
31 @diameter.setter
32 def diameter(self, value):
33 self.radius = value / 2 # Calls radius setter (validates!)
34
35c = Circle(5)
36print(c.radius) # 5
37print(c.area) # 78.539... (computed, not stored)
38print(c.diameter) # 10
39
40c.diameter = 20 # Sets radius to 10 via setter
41print(c.radius) # 10
42
43# c.radius = -1 # ValueError: Radius cannot be negative
44# c.area = 100 # AttributeError: can't set (no setter)
45
46# ============================================================
47# Practical: validated model
48# ============================================================
49class User:
50 def __init__(self, name, email, age):
51 self.name = name # Each calls its setter
52 self.email = email
53 self.age = age
54
55 @property
56 def name(self):
57 return self._name
58
59 @name.setter
60 def name(self, value):
61 if not value or not value.strip():
62 raise ValueError("Name cannot be empty")
63 self._name = value.strip()
64
65 @property
66 def email(self):
67 return self._email
68
69 @email.setter
70 def email(self, value):
71 if "@" not in value:
72 raise ValueError(f"Invalid email: {value}")
73 self._email = value.lower()
74
75 @property
76 def age(self):
77 return self._age
78
79 @age.setter
80 def age(self, value):
81 if not isinstance(value, int) or value < 0 or value > 150:
82 raise ValueError(f"Invalid age: {value}")
83 self._age = value
84
85user = User(" Alice ", "Alice@Email.COM", 30)
86print(user.name) # "Alice" (stripped)
87print(user.email) # "alice@email.com" (lowered)
88
89# ============================================================
90# Lazy property (cached computation)
91# ============================================================
92class DataAnalyzer:
93 def __init__(self, data):
94 self._data = data
95 self._stats = None # Lazy: not computed until needed
96
97 @property
98 def stats(self):
99 if self._stats is None:
100 print("Computing stats...") # Only runs once
101 self._stats = {
102 "mean": sum(self._data) / len(self._data),
103 "min": min(self._data),
104 "max": max(self._data),
105 "count": len(self._data),
106 }
107 return self._stats
108
109analyzer = DataAnalyzer([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
110print(analyzer.stats) # "Computing stats..." then the dict
111print(analyzer.stats) # No message — cached!
112
113# Python 3.8+ has functools.cached_property for this:
114from functools import cached_property
115
116class DataAnalyzerV2:
117 def __init__(self, data):
118 self._data = data
119
120 @cached_property
121 def stats(self):
122 """Computed once, then cached as an instance attribute."""
123 return {
124 "mean": sum(self._data) / len(self._data),
125 "min": min(self._data),
126 "max": max(self._data),
127 }
128
129# ============================================================
130# Descriptors — the mechanism behind @property
131# ============================================================
132class Validated:
133 """A descriptor that validates values on assignment."""
134
135 def __init__(self, validator, error_msg):
136 self.validator = validator
137 self.error_msg = error_msg
138
139 def __set_name__(self, owner, name):
140 """Called when the descriptor is assigned to a class attribute."""
141 self.public_name = name
142 self.private_name = f"_{name}"
143
144 def __get__(self, obj, objtype=None):
145 if obj is None:
146 return self # Access from class, not instance
147 return getattr(obj, self.private_name, None)
148
149 def __set__(self, obj, value):
150 if not self.validator(value):
151 raise ValueError(f"{self.public_name}: {self.error_msg}")
152 setattr(obj, self.private_name, value)
153
154# Reusable validators
155class PositiveNumber(Validated):
156 def __init__(self):
157 super().__init__(
158 lambda x: isinstance(x, (int, float)) and x > 0,
159 "must be a positive number"
160 )
161
162class NonEmptyString(Validated):
163 def __init__(self):
164 super().__init__(
165 lambda x: isinstance(x, str) and len(x.strip()) > 0,
166 "must be a non-empty string"
167 )
168
169class Product:
170 name = NonEmptyString()
171 price = PositiveNumber()
172 quantity = PositiveNumber()
173
174 def __init__(self, name, price, quantity):
175 self.name = name
176 self.price = price
177 self.quantity = quantity
178
179 @property
180 def total(self):
181 return self.price * self.quantity
182
183p = Product("Widget", 9.99, 100)
184print(f"{p.name}: ${p.price} x {p.quantity} = ${p.total}")
185# p.price = -5 # ValueError: price: must be a positive number

🏋️ Practice Exercise

Exercises:

  1. Create a Temperature class with a celsius property and computed fahrenheit and kelvin properties. Setting any of the three should update the underlying value.

  2. Build a ValidatedList class that uses a property to enforce that all items match a given type. vl = ValidatedList(int); vl.items = [1, 2, 3] works, but vl.items = [1, "two"] fails.

  3. Implement a cached_property decorator from scratch (without using functools.cached_property).

  4. Create a reusable TypeChecked descriptor that enforces type on assignment. Use it in a Person class: name: str, age: int, email: str.

  5. Build a ReadOnly descriptor that allows setting a value in __init__ but raises AttributeError on subsequent modifications.

  6. Compare three approaches for the same validation: manual getters/setters, @property, and descriptors. Discuss when each is appropriate.

⚠️ Common Mistakes

  • Storing the value in self.name instead of self._name inside a property setter — this causes infinite recursion because the setter calls itself.

  • Making every attribute a property 'just in case'. Start with plain attributes and convert to properties only when you need validation, computation, or side effects. Properties have overhead.

  • Forgetting that @cached_property only works on instances. It replaces itself with the computed value in the instance dict. It doesn't work with __slots__.

  • Using properties for expensive computations without caching — every access recomputes the value. Use @cached_property or manual caching for expensive operations.

  • Not defining __set_name__ in custom descriptors. Without it, the descriptor doesn't know its attribute name, making error messages unhelpful.

💼 Interview Questions

🎤 Mock Interview

Practice a live interview for Properties & Descriptors