Python Properties
A property in Python lets you define methods that behave like plain attributes. Instead of writing separate get_x() and set_x() methods like in older object-oriented languages, you write normal-looking attribute access (obj.x, obj.x = 5) while a method quietly runs behind the scenes to validate, transform, or compute the value. This gives you the simplicity of plain attributes with the control of methods, and it lets you add validation later without breaking every place in your code that already reads or writes that attribute.
Overview / How it works
In Python, there is no strict distinction between “public” and “private” attributes the way there is in Java or C++. By convention, a single leading underscore (like _balance) signals “this is an implementation detail, please don’t touch it directly,” but nothing stops another programmer from touching it anyway. Properties give you an enforceable version of that convention: you expose a clean public name (balance), while the real data lives in a protected backing attribute (_balance). Any read or write to the public name is intercepted by a method you control.
Under the hood, property is implemented as a descriptor — a class that defines __get__, __set__, and __delete__. When you write @property above a method, Python wraps that method in a property object and stores it on the class, not the instance. Because it defines __set__, it counts as a data descriptor, and data descriptors take priority over an instance’s own __dict__ during attribute lookup. That priority is exactly why obj.balance triggers your getter method instead of returning something from obj.__dict__ directly — the class-level property intercepts the lookup first.
Properties are most useful for three things: validating data on assignment (rejecting a negative age, for example), computing a derived value on the fly (like an area from a radius), and maintaining backward compatibility when a public attribute needs to become computed logic without breaking existing code that treats it as a simple attribute.
Syntax
class ClassName:
@property
def attr_name(self):
return self._attr_name # getter
@attr_name.setter
def attr_name(self, value):
self._attr_name = value # setter
@attr_name.deleter
def attr_name(self):
del self._attr_name # deleter
| Part | Meaning |
|---|---|
@property |
Turns the following method into the getter for the property. Called when you read obj.attr_name. |
@attr_name.setter |
Registers the setter method, reusing the same name as the property. Called when you write obj.attr_name = value. |
@attr_name.deleter |
Registers the deleter method. Called when you run del obj.attr_name. |
self._attr_name |
The conventional backing attribute where the real value is stored. It must have a different name than the property itself. |
You can also skip the decorator syntax entirely and call the built-in property() function directly: attr_name = property(fget, fset, fdel, doc). The decorator form shown above is just syntactic sugar for this and is what you’ll see in almost all modern Python code.
Examples
Example 1: Validated property with a derived value
class Temperature:
def __init__(self, celsius: float = 0.0) -> None:
self._celsius = celsius
@property
def celsius(self) -> float:
return self._celsius
@celsius.setter
def celsius(self, value: float) -> None:
if value < -273.15:
raise ValueError("Temperature below absolute zero is not possible")
self._celsius = value
@property
def fahrenheit(self) -> float:
return self._celsius * 9 / 5 + 32
@fahrenheit.setter
def fahrenheit(self, value: float) -> None:
self.celsius = (value - 32) * 5 / 9
temp = Temperature(25)
print(temp.celsius)
print(temp.fahrenheit)
temp.fahrenheit = 98.6
print(round(temp.celsius, 2))
try:
temp.celsius = -300
except ValueError as e:
print(f"Error: {e}")
Output:
25
77.0
37.0
Error: Temperature below absolute zero is not possible
Here fahrenheit is a fully computed property with no backing attribute of its own — it derives its value from _celsius every time it is read, and its setter simply delegates to the celsius setter so validation only has to live in one place. Setting temp.fahrenheit = 98.6 transparently updates _celsius to 37.0.
Example 2: A read-only computed property
import math
class Circle:
def __init__(self, radius: float) -> None:
if radius <= 0:
raise ValueError("radius must be positive")
self._radius = radius
@property
def radius(self) -> float:
return self._radius
@radius.setter
def radius(self, value: float) -> None:
if value <= 0:
raise ValueError("radius must be positive")
self._radius = value
@property
def area(self) -> float:
return math.pi * self._radius ** 2
@property
def circumference(self) -> float:
return 2 * math.pi * self._radius
c = Circle(3)
print(f"{c.area:.2f}")
print(f"{c.circumference:.2f}")
c.radius = 5
print(f"{c.area:.2f}")
try:
c.area = 100
except AttributeError as e:
print(f"Error: {e}")
Output:
28.27
18.85
78.54
Error: property 'area' of 'Circle' object has no setter
area and circumference only define a getter, so they are read-only. Trying to assign to them raises AttributeError, which is exactly the protection you want for values that only make sense as a function of other data — you don’t want someone to set an area that no longer matches the radius.
Example 3: Using a deleter for cleanup logic
class BankAccount:
def __init__(self, owner: str, balance: float = 0.0) -> None:
self.owner = owner
self._balance = balance
@property
def balance(self) -> float:
return self._balance
@balance.setter
def balance(self, amount: float) -> None:
if amount < 0:
raise ValueError("Balance cannot be negative")
self._balance = amount
@balance.deleter
def balance(self) -> None:
print(f"Closing account for {self.owner}, final balance forfeited.")
self._balance = 0.0
account = BankAccount("Amara", 150.0)
print(account.balance)
account.balance += 50
print(account.balance)
del account.balance
print(account.balance)
Output:
150.0
200.0
Closing account for Amara, final balance forfeited.
0.0
The deleter doesn’t have to remove the attribute at all — here it runs cleanup logic and resets _balance to zero. Note that account.balance += 50 works exactly like a normal attribute: Python reads the current value through the getter, adds 50, and writes the result back through the setter, so validation still applies.
Under the hood: what Python actually does
When the class body executes, @property creates a single property object and binds it to the class under the method’s name. Each subsequent @balance.setter and @balance.deleter call doesn’t create a new attribute — it calls a method on that existing property object which returns a new property object combining the old getter with the new setter or deleter, and reassigns it to the same class attribute name. That’s why all three methods must share the identical name.
At lookup time, obj.balance triggers Python’s attribute protocol: it checks the type’s __mro__ for a data descriptor named balance before ever looking in obj.__dict__. Since property defines both __get__ and __set__, it’s a data descriptor, so it always wins over instance state — this is exactly what makes self._balance = amount safe: _balance is a plain name with no descriptor, so it’s stored directly in obj.__dict__ without recursing back through the property.
Common Mistakes
Mistake 1: Naming the backing attribute the same as the property, causing infinite recursion.
class Config:
def __init__(self, name):
self.name = name
@property
def name(self):
return self.name # BUG: reads self.name -> calls this getter again
@name.setter
def name(self, value):
self.name = value # BUG: assigns to self.name -> calls this setter again
Both methods refer to self.name, which is the property itself, so calling either one calls itself again, forever, until Python raises RecursionError: maximum recursion depth exceeded. The fix is to store the real value under a different name, conventionally with a leading underscore:
class Config:
def __init__(self, name: str) -> None:
self.name = name
@property
def name(self) -> str:
return self._name
@name.setter
def name(self, value: str) -> None:
self._name = value
cfg = Config("staging")
print(cfg.name)
cfg.name = "production"
print(cfg.name)
Output:
staging
production
Mistake 2: Trying to assign to a property that only has a getter. As shown in Example 2, defining just @property without a matching @x.setter makes the attribute read-only; assigning to it raises AttributeError. This is a common surprise when refactoring a plain writable attribute into a computed one — check whether existing code assigns to that attribute before removing its setter.
Best Practices
- Start with plain public attributes. Only introduce a property when you actually need validation, computation, or logging — don’t wrap every attribute in boilerplate “just in case.”
- Keep property getters and setters fast and side-effect-free (aside from intentional cleanup in a deleter). Code that reads
obj.xdoesn’t expect it to make a network call or write to a file. - Always store the backing value under a different name than the property, typically with one leading underscore (
_value), to avoid the infinite-recursion trap. - Raise the standard exception types for invalid input —
ValueErrorfor bad values,TypeErrorfor wrong types — so callers can catch them predictably. - For an expensive computed value that never changes after being computed once, use
functools.cached_propertyinstead of@propertyso the calculation runs only once per instance. - Define read-only properties (getter only, no setter) for values that should always be derived from other state, like an area from a radius.
- Document the property with a docstring on the getter method — tools like
help()and IDEs will surface it.
Practice Exercises
Exercise 1: Write a Rectangle class with width and height properties that raise ValueError if set to zero or a negative number, plus a read-only area property. Verify that assigning rect.area = 10 raises AttributeError.
Exercise 2: Write a Person class with a name property whose setter automatically stores the value title-cased (so person.name = "ada lovelace" results in person.name returning "Ada Lovelace").
Exercise 3: Add a deleter to the Person class from Exercise 2 that resets name to "Unknown" instead of actually deleting the underlying attribute, and print the result of del person.name followed by person.name.
Summary
- A property lets attribute-style access (
obj.x,obj.x = v) trigger method logic behind the scenes. @propertydefines the getter;@x.setterand@x.deleteradd the setter and deleter, all sharing the same method name.- Properties are implemented as data descriptors, which is why they take priority over an instance’s own
__dict__. - Always back a property with a differently-named attribute (like
_x) to avoid infinite recursion. - A property with no setter is read-only; assigning to it raises
AttributeError. - Use properties for validation and computed values, not as a default for every attribute — plain attributes are simpler and are exactly what most attributes should be.
