AiTechWorlds
AiTechWorlds
A thermostat has a temperature dial. You can turn it to set the temperature. But if you try to set it to 200°C, it pushes back — that's dangerous, and the thermostat won't allow it. When you read the display, it shows you the current temperature in whatever unit you prefer: Celsius, Fahrenheit, or Kelvin.
The thermostat is doing something clever: it looks like a simple knob you can read and write, but behind the scenes it's running validation logic, conversion logic, and safety checks.
Python properties are your thermostat mechanism. They let you expose what looks like a plain attribute (temperature) while secretly running methods every time someone reads or writes it. Users of your class get the simplicity of attribute access; you get full control over what happens.
The naive approach to validation is to use explicit getter and setter methods:
class Temperature:
def get_celsius(self):
return self._celsius
def set_celsius(self, value):
if value < -273.15:
raise ValueError("Temperature below absolute zero!")
self._celsius = value
This works, but it's clunky: t.set_celsius(100) instead of t.celsius = 100. Users have to remember to call the right method.
Properties give you the clean
obj.attribute = valuesyntax and the power of a method running underneath.
@property Decoratorclass Temperature:
def __init__(self, celsius: float = 0.0):
# Note: we call the setter here, so validation runs on init too
self.celsius = celsius # This calls the @setter below
@property
def celsius(self) -> float:
# Called when you READ: t.celsius
return self._celsius # Return the private backing attribute
@celsius.setter
def celsius(self, value: float):
# Called when you WRITE: t.celsius = 25
if value < -273.15:
raise ValueError(f"{value}°C is below absolute zero (-273.15°C)")
self._celsius = value # Store in private attribute with underscore prefix
@celsius.deleter
def celsius(self):
# Called when you DELETE: del t.celsius
print("Resetting temperature to 0°C")
self._celsius = 0.0 # Reset to default instead of actual deletion
Using the property:
t = Temperature(25)
print(t.celsius) # 25 — calls the getter
t.celsius = 100 # OK — calls the setter, validation passes
t.celsius = -300 # ValueError: -300°C is below absolute zero
Properties don't have to store anything — they can compute a value on the fly. This is perfect for unit conversions that must stay in sync.
class Temperature:
def __init__(self, celsius: float = 0.0):
self.celsius = celsius # Store only ONE value internally
@property
def celsius(self) -> float:
return self._celsius
@celsius.setter
def celsius(self, value: float):
if value < -273.15:
raise ValueError(f"{value}°C is below absolute zero")
self._celsius = value
@property
def fahrenheit(self) -> float:
# Computed from Celsius — no separate storage needed
# Formula: F = (C × 9/5) + 32
return (self._celsius * 9 / 5) + 32
@fahrenheit.setter
def fahrenheit(self, value: float):
# Convert Fahrenheit to Celsius, then store via the Celsius setter
# Formula: C = (F − 32) × 5/9
self.celsius = (value - 32) * 5 / 9 # Validation runs automatically!
@property
def kelvin(self) -> float:
# Computed from Celsius — Kelvin = Celsius + 273.15
return self._celsius + 273.15
@kelvin.setter
def kelvin(self, value: float):
# Convert Kelvin to Celsius, validation runs via celsius setter
self.celsius = value - 273.15
def __repr__(self) -> str:
return (f"Temperature({self._celsius:.2f}°C / "
f"{self.fahrenheit:.2f}°F / "
f"{self.kelvin:.2f}K)")
All three units stay in perfect sync:
t = Temperature(0)
print(t) # Temperature(0.00°C / 32.00°F / 273.15K)
t.fahrenheit = 212 # Set via Fahrenheit — converts to Celsius internally
print(t.celsius) # 100.0
print(t.kelvin) # 373.15
t.kelvin = 0 # ValueError: -273.15°C is below absolute zero
Omit the setter to create a property users can read but never write directly.
class Circle:
def __init__(self, radius: float):
self.radius = radius # Mutable: users can change the radius
@property
def area(self) -> float:
# Read-only: area is always derived from radius, never stored separately
import math
return math.pi * self.radius ** 2
@property
def circumference(self) -> float:
import math
return 2 * math.pi * self.radius
c = Circle(5)
print(c.area) # 78.53981...
c.area = 100 # AttributeError: can't set attribute
Properties are built on top of a lower-level system called descriptors. A descriptor is any class that defines __get__, __set__, or __delete__. When you put a descriptor instance as a class attribute, Python calls its methods automatically.
class ValidatedField:
"""A reusable descriptor that validates any attribute."""
def __init__(self, min_value=None, max_value=None):
self.min_value = min_value # Optional lower bound
self.max_value = max_value # Optional upper bound
self.name = None # Set by __set_name__ when class is defined
def __set_name__(self, owner, name):
# Called automatically when the descriptor is assigned as a class attribute
self.name = f"_{name}" # Store under private name to avoid recursion
def __get__(self, obj, objtype=None):
# Called when the attribute is READ from an instance
if obj is None:
return self # Accessed from class, not instance — return descriptor
return getattr(obj, self.name, None) # Return stored value
def __set__(self, obj, value):
# Called when the attribute is WRITTEN to
if self.min_value is not None and value < self.min_value:
raise ValueError(f"{self.name} must be >= {self.min_value}, got {value}")
if self.max_value is not None and value > self.max_value:
raise ValueError(f"{self.name} must be <= {self.max_value}, got {value}")
setattr(obj, self.name, value) # Store the validated value
def __delete__(self, obj):
# Called when the attribute is DELETED
delattr(obj, self.name)
class Person:
# These descriptor instances are CLASS attributes — they handle all instances
age = ValidatedField(min_value=0, max_value=150)
height_cm = ValidatedField(min_value=0, max_value=300)
weight_kg = ValidatedField(min_value=0, max_value=500)
def __init__(self, name: str, age: int, height_cm: float, weight_kg: float):
self.name = name
self.age = age # Triggers ValidatedField.__set__
self.height_cm = height_cm # Triggers ValidatedField.__set__
self.weight_kg = weight_kg # Triggers ValidatedField.__set__
Using the descriptor:
p = Person("Alice", age=30, height_cm=165, weight_kg=60)
print(p.age) # 30
p.age = 200 # ValueError: _age must be <= 150, got 200
p.age = -5 # ValueError: _age must be >= 0, got -5
The ValidatedField descriptor is reusable across any class. You write the validation logic once and apply it to any attribute on any class.
| Feature | @property | Descriptor |
|---|---|---|
| Scope | One attribute in one class | Reusable across many classes |
| Complexity | Simple to write | More setup, but far more powerful |
| Use Case | Validation + computed values in one place | Shared validation, ORM-style fields |
| Example | celsius ↔ fahrenheit sync | ValidatedField, Django model fields |
@property makes attributes smart — they can validate, transform, and compute values while appearing as plain attributes to callers.Temperature example share one backing value — only one is stored, the others are computed. This eliminates sync bugs.The thermostat dial looks simple. Behind it is a complete control system. Properties are how you build that in Python.
Get this course's notes on Telegram!
Free cheat sheets, summaries & practice exercises