The fastest way to spot a Python programmer who just came from Java: every class has a private _name, a get_name(), and a set_name(value). It works, but it's noise. Python doesn't need any of that. You write self.name = "Rex" directly. If you later need validation or computation, you swap in @property under the same attribute name and every caller keeps working. That's the uniform access principle, baked into the language since Python 2.2 via PEP 252. Under the hood, @property is just a descriptor: a class-level object that hooks attribute access through __get__, __set__, and __delete__. This entry covers when bare attributes win, when @property earns its keep, how the descriptor dispatch actually works, the cached_property variant for expensive computation, and the boilerplate patterns to avoid. One of 10 explainers in our Python OOP complete guide.
Key Takeaways
Default to bare attributes. Use @property only when access needs validation, computation, or side effects.
@property is a descriptor. It hooks attribute access via __get__, __set__, and __delete__ on a class-level object.
Read-only is easy: just define the getter, skip the setter. Any assignment raises AttributeError.
functools.cached_property stores the computed value on first access and returns it directly thereafter — useful for expensive, never-changing computations.
Uniform access principle: upgrade bare self.x to @property without breaking callers. That's why "preemptive getters" are anti-Pythonic.
Three call chains: GET, SET, DELETE. The descriptor sits between user code and your getter/setter/deleter.
The Pythonic Default: Bare Attributes
Before reaching for @property, start with a plain attribute.
class Product:
def __init__(self, name, price):
self.name = name
self.price = price
p = Product("Widget", 9.99)
p.price = 12.50 # Just works.
This is what Python wants you to do by default. The PSF docs on property and the wider community guidance agree: don't write getter/setter pairs preemptively. The reason is the uniform access principle (Bertrand Meyer's term from 1988): clients should not be able to tell whether a service is implemented through storage or computation. Python honors it by letting you swap a bare attribute for a @property of the same name without breaking any caller. The Java reflex to wrap everything in get_x()/set_x() exists because Java couldn't do that swap. Python can, so don't.
When @property Earns Its Keep
Reach for @property when accessing the attribute needs to do something. The four real use cases:
1. Validation on assignment
class Product:
def __init__(self, name, price):
self.name = name
self.price = price # Goes through the setter
@property
def price(self):
return self._price
@price.setter
def price(self, value):
if value < 0:
raise ValueError(f"price must be non-negative, got {value}")
self._price = value
p = Product("Widget", 9.99)
p.price = -5
# ValueError: price must be non-negative, got -5
The setter rejects bad values BEFORE they're stored. The underscored _price is the actual storage; the public price is the validated gate.
2. Computed attributes
from math import pi
class Circle:
def __init__(self, radius):
self.radius = radius
@property
def area(self):
return pi * self.radius ** 2
@property
def diameter(self):
return 2 * self.radius
c = Circle(5)
print(c.area) # 78.54...
print(c.diameter) # 10
No setter, so area and diameter are read-only computed views. Callers read them like plain attributes (c.area, no parentheses), but the value is always recomputed from radius.
3. Read-only published value
class Order:
def __init__(self, order_id, items):
self._id = order_id
self.items = items
@property
def order_id(self):
return self._id # No setter — immutable from outside
o = Order("ORD-42", [...])
o.order_id = "ORD-99"
# AttributeError: property 'order_id' of 'Order' object has no setter
The order ID is set in __init__ and never changes. The lack of a .setter turns assignment attempts into a clear error.
Every read bumps a counter. Useful for instrumentation, lazy initialization, or audit logs. Use sparingly: properties that mutate state when read can surprise people who expect attribute access to be free.
The Three Decorator Forms
The full @property trio (getter, setter, deleter) follows a strict pattern: the second and third decorators are @<same-name>.setter and @<same-name>.deleter, not bare @setter.
class Temperature:
def __init__(self, celsius=0):
self._celsius = celsius
@property
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError("below absolute zero")
self._celsius = value
@celsius.deleter
def celsius(self):
print("Resetting to default 0°C")
self._celsius = 0
t = Temperature(20)
t.celsius = 25 # Setter
print(t.celsius) # Getter → 25
del t.celsius # Deleter → "Resetting to default 0°C"
The deleter is rarely needed in practice. Most production code uses getter + setter, or just a getter for read-only.
Under the Hood: It's a Descriptor
The reason @property works is the descriptor protocol, documented in the PSF Descriptor HowTo Guide by Raymond Hettinger. A descriptor is any class-level object that defines at least one of __get__, __set__, or __delete__. When Python looks up an attribute on an instance, it checks the class first. If it finds a descriptor with __set__ (a data descriptor), it calls the descriptor's methods instead of touching the instance dictionary.
# Without the decorator syntax, equivalent code:
class Product:
def _get_price(self):
return self._price
def _set_price(self, value):
if value < 0:
raise ValueError("negative")
self._price = value
price = property(fget=_get_price, fset=_set_price)
The decorator form is shorthand for this constructor call. property(fget, fset, fdel, doc) creates a descriptor object that lives on the class. When you access obj.price, Python sees the descriptor and calls property.__get__(obj, type(obj)), which in turn calls fget(obj). Same chain for assignment and deletion.
This is also why properties are class-level, not instance-level. The descriptor lives on Product, not on any single p = Product(...) instance. Every instance shares the same property object; the per-instance state lives in self._price.
functools.cached_property
For expensive computations whose result doesn't change for the instance's lifetime, @functools.cached_property (added in Python 3.8) is the right tool.
from functools import cached_property
class Dataset:
def __init__(self, path):
self.path = path
@cached_property
def stats(self):
print("Computing stats...") # Runs once
return expensive_parse(self.path)
d = Dataset("/data.csv")
print(d.stats) # "Computing stats..." then result
print(d.stats) # Cached — no recomputation
On first access, cached_property runs the function and stores the result in the instance's __dict__ under the same name. From the second access on, normal attribute lookup finds the cached value first (since cached_property is a NON-data descriptor — no __set__ — the instance dict wins).
cached_property caveats:
Requires the instance to have __dict__ (incompatible with __slots__ unless you list the cached name).
The cache lives until the instance is garbage collected. There's no built-in invalidation.
For values that DO change, use regular @property, not cached_property.
Anti-Patterns to Avoid
Preemptive getter/setter for every attribute
Java reflex. Don't do it.
# Bad — pure noise, no value added
class Person:
def __init__(self, name):
self._name = name
@property
def name(self):
return self._name
@name.setter
def name(self, value):
self._name = value
# Good — same behavior, half the code
class Person:
def __init__(self, name):
self.name = name
Every attribute that's just storage stays as bare storage. Add @property when there's real work behind the dot.
Infinite recursion in the setter
# Bug — assigning to self.price calls the setter again, forever
class Product:
@property
def price(self):
return self.price # RecursionError
@price.setter
def price(self, value):
self.price = value # Same RecursionError
Use a different name for the underlying storage: self._price (or self.__price for name-mangled). Otherwise the property keeps calling itself.
Heavy computation in a getter without caching
If obj.foo takes 2 seconds, every read pays that cost. Move the work to cached_property or to an explicit compute_foo() method so callers know they're paying for it.
Side effects in getters
Generally surprising. A reader who sees x = obj.value doesn't expect that line to write to disk, increment counters, or hit the network. Reserve side-effecting getters for narrow cases (instrumentation, lazy init) and document them clearly.
Overriding Properties in Subclasses
The gotcha that bites every intermediate Python developer: you have a property on the base class, and a subclass wants to change only the setter (keep the getter). The naive attempt fails because @x.setter requires the x property from the SAME class scope.
class Animal:
@property
def name(self):
return self._name
@name.setter
def name(self, value):
self._name = value.strip()
class Dog(Animal):
@Animal.name.setter # Reference the parent's property
def name(self, value):
self._name = value.strip().capitalize() # Override behavior
d = Dog()
d.name = " rex "
print(d.name) # 'Rex'
The key: @Animal.name.setter explicitly references the parent's name descriptor and registers a new setter against it. If you skipped this and just wrote @name.setter, Python would look for a name in Dog's scope — which doesn't exist yet — and raise NameError.
If you want to override BOTH getter and setter in the subclass, define a fresh property:
This shadows the parent's property entirely. Pick the pattern based on whether you're tweaking one piece or replacing the whole thing.
When @property Isn't Enough: Custom Descriptors
If you find yourself writing the same validation pattern across many attributes, the cleanest path is a custom descriptor — the lower-level construct that property is built on. The Descriptor HowTo Guide walks through the full pattern; here's the shape:
class NonNegative:
def __set_name__(self, owner, name):
self.private_name = f"_{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 non-negative")
setattr(obj, self.private_name, value)
class Product:
price = NonNegative()
quantity = NonNegative()
weight = NonNegative()
def __init__(self, price, quantity, weight):
self.price = price
self.quantity = quantity
self.weight = weight
Three attributes, one shared validator, zero boilerplate per attribute. The __set_name__ hook (PEP 487) tells the descriptor what attribute name it was assigned to, so it can manage its own private storage. Write a custom descriptor when the same logic repeats across attributes; use @property for one-off cases.
The other deep version is the type-annotated descriptor pattern used by libraries like SQLAlchemy and Pydantic. Both build on the same protocol: a class-level object that intercepts attribute access. @property is the easy on-ramp; descriptors are the full road.
Real-World Example: Validated Account Balance
class Account:
def __init__(self, initial_balance=0):
self._balance = 0
self.balance = initial_balance # Validates via setter
@property
def balance(self):
return self._balance
@balance.setter
def balance(self, value):
if not isinstance(value, (int, float)):
raise TypeError(f"balance must be numeric, got {type(value).__name__}")
if value < 0:
raise ValueError(f"balance cannot be negative, got {value}")
self._balance = value
@property
def is_overdrawn(self):
return self._balance < 0 # Always False given the setter, but expresses intent
a = Account(100)
a.balance = 50
print(a.balance) # 50
a.balance = "100"
# TypeError: balance must be numeric, got str
a.balance = -10
# ValueError: balance cannot be negative, got -10
The setter enforces both type and range; balance stays a clean attribute interface; the is_overdrawn computed property documents intent and gives derived classes a hook to override.
@property vs Just Calling a Method
Use case
Choose
Looks like a property of the object
@property
Cheap to compute (O(1) or near it)
@property
Reading shouldn't have visible side effects
@property
Looks like an action or operation
Method obj.do_x()
Expensive (network, file, heavy math)
Method, or cached_property if cacheable
Takes arguments beyond self
Method (properties can't accept args)
The rule of thumb: if obj.x reads naturally as "the x of obj" and is cheap, it's a property. If it reads as "do the x thing", it's a method.
Frequently Asked Questions
When should I use @property in Python?
Use @property when accessing or setting an attribute needs to do something beyond reading or writing a stored value: validate a new value, compute a value from other attributes, trigger a side effect, or expose a read-only view. If a plain attribute would do the job, use a plain attribute. Python's philosophy favors direct attribute access; @property earns its keep only when there's real work to do behind the dot. The Java-style "every attribute gets a getter and setter" pattern is anti-Pythonic and adds noise without value.
How does @property work under the hood?
@property creates a descriptor: a class-level object that implements the descriptor protocol (__get__, __set__, __delete__). When you access obj.price on an instance, Python looks up price on the class, finds the property descriptor, and calls property.__get__(obj, type(obj)), which dispatches to your getter function. Assignment dispatches to __set__; del dispatches to __delete__. Properties are data descriptors (they define __set__), which means they take precedence over the instance dictionary. This is also why you can't "shadow" a property by assigning to obj.price directly — the descriptor intercepts the assignment.
How do I make a read-only property in Python?
Define only the getter — don't add @price.setter. Any attempt to assign to it raises AttributeError. This is the cleanest way to expose computed values or values that must not be mutated after construction. For example, a circle's area should always be derived from its radius; you define @property def area(self): return pi * self.radius ** 2 with no setter, and area becomes a read-only computed attribute. Users can still try obj.area = 5, but Python raises AttributeError because no setter exists.
What is functools.cached_property and when should I use it?
functools.cached_property (added in Python 3.8) computes the value once on first access and stores it in the instance dictionary, so subsequent accesses are plain attribute lookups with no method call overhead. Use it when the computation is expensive (database query, file parse, heavy math) AND the result won't change for the instance's lifetime. It's NOT a drop-in replacement for @property: cached_property requires __dict__ on the instance (incompatible with __slots__), and it caches forever until the instance is garbage collected. For values that DO change, stick with regular @property.
Why does Python recommend bare attributes over getters and setters?
Because Python supports the uniform access principle: if you start with a bare attribute (self.price = 100) and later need validation or computation, you can replace it with a @property with the SAME name, and all existing callers continue to work. obj.price still reads like an attribute. There's no need to defensively wrap every attribute in a getter "just in case" the way Java demands, because Python lets you upgrade in place. This means YAGNI applies hard to getters and setters: write them only when the use case is real, not hypothetical.
The Bottom Line: Default to Bare, Upgrade When You Need To
Bare attributes are the Pythonic default. Reach for @property when access needs validation, computation, read-only protection, or controlled side effects. Under the hood it's a descriptor on the class, dispatched through __get__/__set__/__delete__; the descriptor's data nature is what lets the uniform access principle work. Use functools.cached_property for expensive, never-changing computations; use a regular method when the operation takes arguments or looks like an action. Next up: the 12 magic methods (dunders) you'll actually use. Or browse the full Python OOP guide.
Practice Properties Until They're Reflex
CodeGym's Python track builds attribute-access intuition through 800+ tasks across 62 levels, with hands-on exercises for validation setters, computed properties, and cached_property trade-offs. AI validators catch missing edge cases; AI mentors explain why a setter recursion bug fires. First level free; full plan on the pricing page.
GO TO FULL VERSION