Properties & Descriptors
📖 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 (
areafromradius) - 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
1# ============================================================2# @property basics3# ============================================================4class Circle:5 def __init__(self, radius):6 self.radius = radius # Calls the setter!78 @property9 def radius(self):10 """The radius of the circle."""11 return self._radius1213 @radius.setter14 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 = value2021 @property22 def area(self):23 """Computed property — calculated on access."""24 import math25 return math.pi * self._radius ** 22627 @property28 def diameter(self):29 return self._radius * 23031 @diameter.setter32 def diameter(self, value):33 self.radius = value / 2 # Calls radius setter (validates!)3435c = Circle(5)36print(c.radius) # 537print(c.area) # 78.539... (computed, not stored)38print(c.diameter) # 103940c.diameter = 20 # Sets radius to 10 via setter41print(c.radius) # 104243# c.radius = -1 # ValueError: Radius cannot be negative44# c.area = 100 # AttributeError: can't set (no setter)4546# ============================================================47# Practical: validated model48# ============================================================49class User:50 def __init__(self, name, email, age):51 self.name = name # Each calls its setter52 self.email = email53 self.age = age5455 @property56 def name(self):57 return self._name5859 @name.setter60 def name(self, value):61 if not value or not value.strip():62 raise ValueError("Name cannot be empty")63 self._name = value.strip()6465 @property66 def email(self):67 return self._email6869 @email.setter70 def email(self, value):71 if "@" not in value:72 raise ValueError(f"Invalid email: {value}")73 self._email = value.lower()7475 @property76 def age(self):77 return self._age7879 @age.setter80 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 = value8485user = User(" Alice ", "Alice@Email.COM", 30)86print(user.name) # "Alice" (stripped)87print(user.email) # "alice@email.com" (lowered)8889# ============================================================90# Lazy property (cached computation)91# ============================================================92class DataAnalyzer:93 def __init__(self, data):94 self._data = data95 self._stats = None # Lazy: not computed until needed9697 @property98 def stats(self):99 if self._stats is None:100 print("Computing stats...") # Only runs once101 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._stats108109analyzer = DataAnalyzer([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])110print(analyzer.stats) # "Computing stats..." then the dict111print(analyzer.stats) # No message — cached!112113# Python 3.8+ has functools.cached_property for this:114from functools import cached_property115116class DataAnalyzerV2:117 def __init__(self, data):118 self._data = data119120 @cached_property121 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 }128129# ============================================================130# Descriptors — the mechanism behind @property131# ============================================================132class Validated:133 """A descriptor that validates values on assignment."""134135 def __init__(self, validator, error_msg):136 self.validator = validator137 self.error_msg = error_msg138139 def __set_name__(self, owner, name):140 """Called when the descriptor is assigned to a class attribute."""141 self.public_name = name142 self.private_name = f"_{name}"143144 def __get__(self, obj, objtype=None):145 if obj is None:146 return self # Access from class, not instance147 return getattr(obj, self.private_name, None)148149 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)153154# Reusable validators155class 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 )161162class 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 )168169class Product:170 name = NonEmptyString()171 price = PositiveNumber()172 quantity = PositiveNumber()173174 def __init__(self, name, price, quantity):175 self.name = name176 self.price = price177 self.quantity = quantity178179 @property180 def total(self):181 return self.price * self.quantity182183p = 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:
Create a
Temperatureclass with acelsiusproperty and computedfahrenheitandkelvinproperties. Setting any of the three should update the underlying value.Build a
ValidatedListclass that uses a property to enforce that all items match a given type.vl = ValidatedList(int); vl.items = [1, 2, 3]works, butvl.items = [1, "two"]fails.Implement a
cached_propertydecorator from scratch (without usingfunctools.cached_property).Create a reusable
TypeCheckeddescriptor that enforces type on assignment. Use it in aPersonclass:name: str,age: int,email: str.Build a
ReadOnlydescriptor that allows setting a value in__init__but raisesAttributeErroron subsequent modifications.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.nameinstead ofself._nameinside 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_propertyonly 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_propertyor 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