Python's @property decorator lets you expose a method as if it were a plain attribute. You write obj.value (no parentheses), but behind that simple access Python runs your getter, setter, or deleter code. Use it when you need a computed attribute (a value derived from other data), validation on assignment, lazy or read-only access, or when you want to add logic to an existing attribute without changing the public API that callers already depend on.
The key win is that @property keeps the simple obj.value syntax while giving you full control. You start with a plain attribute, and the day you need validation or computation, you convert it to a property and no calling code has to change. That is why seasoned Python developers reach for properties instead of writing Java-style get_value() / set_value() methods.
At MicroPyramid we have spent 12+ years and 50+ projects building Python and Django applications, and clean attribute design is one of the small decisions that keeps a codebase maintainable as it grows. This guide walks through every part of the property protocol with current Python 3.11+ examples.
The problem: getters and setters are un-Pythonic
If you come from Java or C++, your instinct is to make attributes "private" and wrap them in accessor methods:
# Don't do this in Python
class Circle:
def __init__(self, radius):
self._radius = radius
def get_radius(self):
return self._radius
def set_radius(self, value):
self._radius = value
c = Circle(5)
print(c.get_radius()) # verbose
c.set_radius(10) # verboseThis forces every caller to use get_radius() and set_radius() for no reason. The Pythonic approach is the opposite: start with a plain public attribute, and only add logic later if you actually need it.
class Circle:
def __init__(self, radius):
self.radius = radius # just a plain attribute
c = Circle(5)
print(c.radius) # 5
c.radius = 10 # works, no boilerplate
When you later need validation or a computed value, @property lets you intercept attribute access while keeping that exact c.radius syntax. Callers never know the difference.
The @property getter
Decorate a method with @property and it becomes readable as an attribute. By convention the underlying data lives in a "private" attribute prefixed with a single underscore (_radius):
class Circle:
def __init__(self, radius):
self._radius = radius
@property
def radius(self):
"""The circle's radius."""
return self._radius
c = Circle(5)
print(c.radius) # 5 -> calls the getter, no parentheses
c.radius = 10 # AttributeError: property 'radius' has no setterRight now radius is read-only: accessing it works, but assignment raises AttributeError because we have not defined a setter yet. That is already useful for values that should never change after construction. The docstring on the getter becomes the property's __doc__, so help(Circle.radius) works as expected.
Adding a setter with validation
To make the property writable, define a second method with the same name decorated with @<name>.setter. This is the single most common reason to use a property: validate or normalise a value before storing it.
class Circle:
def __init__(self, radius):
self.radius = radius # goes through the setter -> validates on construction too
@property
def radius(self):
return self._radius
@radius.setter
def radius(self, value):
if not isinstance(value, (int, float)):
raise TypeError("radius must be a number")
if value < 0:
raise ValueError("radius cannot be negative")
self._radius = value
c = Circle(5)
c.radius = 10 # OK
c.radius = -1 # ValueError: radius cannot be negative
c.radius = "big" # TypeError: radius must be a numberNotice that __init__ assigns to self.radius (the public name), not self._radius. This means the validation runs even during construction, so you can never create an invalid object. The setter stores the validated value in the private _radius to avoid infinite recursion (more on that pitfall below).
The deleter
The third piece of the protocol is @<name>.deleter, which runs when someone calls del obj.attr. It is the least-used part, but handy when deleting an attribute should also clean up related state:
class Circle:
def __init__(self, radius):
self._radius = radius
@property
def radius(self):
return self._radius
@radius.setter
def radius(self, value):
self._radius = value
@radius.deleter
def radius(self):
print("Deleting radius")
del self._radius
c = Circle(5)
del c.radius # prints "Deleting radius", removes the underlying valueThe functional property() form
The decorator syntax is just sugar over the built-in property() callable. Its full signature is property(fget=None, fset=None, fdel=None, doc=None). You can build a property explicitly, which is occasionally useful when you already have standalone functions or want to attach getters/setters programmatically:
class Circle:
def __init__(self, radius):
self.radius = radius
def _get_radius(self):
return self._radius
def _set_radius(self, value):
if value < 0:
raise ValueError("radius cannot be negative")
self._radius = value
def _del_radius(self):
del self._radius
radius = property(_get_radius, _set_radius, _del_radius, "The circle's radius.")
c = Circle(5)
print(c.radius) # 5The decorator form (@property, @radius.setter) is preferred in modern code because it reads more clearly and keeps each accessor next to its name. The functional form is the same machinery underneath — every @x.setter call simply returns a new property object built from the old one plus the new setter function.
Read-only and computed properties
A property with only a getter is the idiomatic way to expose derived values that should be calculated on demand rather than stored. The classic example is converting between units, or any value computed from other attributes:
class Celsius:
def __init__(self, temperature=0):
self.temperature = temperature # validated via the setter
@property
def temperature(self):
return self._temperature
@temperature.setter
def temperature(self, value):
if value < -273.15:
raise ValueError("Temperature below absolute zero is not possible")
self._temperature = value
@property
def fahrenheit(self):
# computed on every access, always in sync with temperature
return (self._temperature * 1.8) + 32
c = Celsius(25)
print(c.fahrenheit) # 77.0
c.temperature = 30
print(c.fahrenheit) # 86.0 -> recomputed, never stalefahrenheit is computed fresh on every access, so it can never drift out of sync with temperature. There is no _fahrenheit field to keep updated. This is the core benefit of computed properties: one source of truth, derived views stay correct automatically.
Caching expensive properties with functools.cached_property
Computing on every access is wasteful when the value is expensive and does not change. Since Python 3.8 the standard library ships functools.cached_property: it computes the value the first time, stores it in the instance __dict__ under the same name, and returns the cached result on every subsequent access. The method body never runs again.
from functools import cached_property
class Dataset:
def __init__(self, rows):
self.rows = rows
@cached_property
def summary(self):
print("Computing summary (expensive)...")
return {
"count": len(self.rows),
"total": sum(self.rows),
}
d = Dataset([1, 2, 3, 4])
print(d.summary) # prints "Computing summary..." then the dict
print(d.summary) # cached -> no recompute, just returns the stored dict
# Invalidate the cache by deleting the cached attribute:
del d.summary
print(d.summary) # recomputes once moreTwo things to know about cached_property:
- It is read-write by default because it shadows itself with a plain instance attribute. After the first access,
d.__dict__['summary']exists and is returned directly — you can even assign to it ordelit to invalidate the cache. - It needs a writable instance
__dict__, so it does not work on classes that define__slots__(unless__dict__is in the slots). A regular@propertyworks fine with__slots__.
Use @property for cheap, always-fresh derived values; use @cached_property for expensive values that stay constant for the object's lifetime (parsed config, aggregate statistics, a compiled regex).
Properties vs attributes vs slots: when to use each
Properties are not free — each access is a method call, and they add indirection. Reach for the simplest tool that solves the problem. The table below summarises the trade-offs:
| Mechanism | Stored where | Recomputes? | Validation/logic | Best for |
|---|---|---|---|---|
| Plain attribute | instance __dict__ |
n/a | none | simple mutable data with no rules |
@property |
backing _field you manage |
every access | yes (get/set/del) | validation, read-only, derived values |
functools.cached_property |
instance __dict__ (self-shadowing) |
once, then cached | on first access only | expensive values that don't change |
| Custom descriptor | up to the descriptor | up to you | reusable across many attrs/classes | the same logic on many attributes |
__slots__ |
fixed slot, no __dict__ |
n/a | none | memory savings on many small objects |
Rule of thumb: start with a plain attribute. Switch to @property when you need validation, read-only access, or a computed value. Use cached_property when that computed value is expensive and stable. Reach for a full descriptor only when you need to share the same attribute logic across many fields or classes.
How properties relate to the descriptor protocol
Under the hood, property is itself a descriptor — an object that defines __get__, __set__, and/or __delete__ and lives on the class. When you access instance.attr, Python finds the property descriptor on the class and calls its __get__; assignment calls __set__; del calls __delete__. That is the entire mechanism behind the magic.
Knowing this matters when you need to apply the same logic to many attributes — writing one getter/setter pair per field gets repetitive. A reusable descriptor solves that:
class Positive:
"""A reusable descriptor: any attribute using it must be a positive number."""
def __set_name__(self, owner, name):
# Python 3.6+ tells the descriptor the attribute name it was assigned to
self.private_name = "_" + name
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.private_name)
def __set__(self, obj, value):
if value <= 0:
raise ValueError(f"{self.private_name[1:]} must be positive")
setattr(obj, self.private_name, value)
class Rectangle:
width = Positive()
height = Positive()
def __init__(self, width, height):
self.width = width
self.height = height
r = Rectangle(4, 5)
r.width = -1 # ValueError: width must be positiveA property is the simplest specialised descriptor; a custom descriptor class is the general case. If you find yourself copying the same property getter/setter across many attributes, that is the signal to write a descriptor instead.
Common pitfalls
1. Infinite recursion in the setter. The most common property bug is assigning to the public name inside the setter instead of the private backing field:
class Broken:
@property
def value(self):
return self._value
@value.setter
def value(self, v):
self.value = v # BUG: calls the setter again -> RecursionError
The assignment self.value = v re-triggers the setter, which assigns again, forever. Always store into a different private attribute (self._value = v).
2. Properties live on the class, not the instance. A property only works when defined on the class. Assigning a property() object to self in __init__ does nothing useful — Python only invokes the descriptor protocol for attributes found on the type. (In old Python 2, properties also silently failed on old-style classes; in Python 3 all classes are new-style, so this is no longer a concern.)
3. Setting the public name in __init__. When __init__ does self.radius = radius, it correctly routes through the setter and validates. That is intentional and good — just make sure the setter writes to self._radius, not self.radius.
4. cached_property plus __slots__. As noted above, cached_property needs an instance __dict__, so it breaks on slotted classes. Use a plain @property (recomputing each time) or add __dict__ to __slots__.
Frequently Asked Questions
When should I use @property vs a plain attribute?
Use a plain attribute by default — it is the simplest and fastest option. Switch to @property only when you need to add behaviour to attribute access: validating or normalising a value on assignment, exposing a read-only value, or returning a computed value derived from other data. Because a property keeps the exact obj.attr syntax, you can start with a plain attribute and convert it to a property later without changing any calling code.
What is the difference between @property and @cached_property?
A @property getter runs on every access, so the value is always fresh — ideal for cheap, derived values that must stay in sync. functools.cached_property runs the method only the first time, stores the result in the instance __dict__, and returns that cached value thereafter, so the body never runs again. Use cached_property for expensive values that do not change for the object's lifetime, and invalidate it with del obj.attr when you need a recompute.
Why is my property setter causing infinite recursion?
Because the setter is assigning to the public property name instead of a private backing field. If your setter does self.value = v, that assignment calls the setter again, which calls it again, until you hit RecursionError. Store the value in a different attribute, conventionally one prefixed with an underscore: self._value = v. The getter then reads back from self._value.
Can I create a write-only property?
Yes. Define a setter (and optionally a deleter) but make the getter raise AttributeError, for example raise AttributeError("this attribute is write-only"). Write-only properties are rare — they are occasionally used for things like a password field you can set but never read back — but the property protocol fully supports them.
Do properties work with slots?
A regular @property works perfectly with __slots__: the property descriptor lives on the class and stores data in a declared slot such as _value. functools.cached_property, however, needs a writable instance __dict__ to store its cache, so it does not work on a slotted class unless you also add '__dict__' to __slots__, which defeats much of the memory benefit of slots.
Are properties slower than plain attributes?
Yes, slightly — a property access is a Python-level method call, while a plain attribute is a direct dictionary lookup, so properties carry a small overhead. For almost all application code this is irrelevant. Only in extremely hot loops over millions of accesses would it matter, in which case you can read the underlying _field directly or cache the value. Do not avoid properties for premature performance reasons; reach for them whenever they make the code clearer or safer.