In languages like Java and C#, getter and setter methods are everywhere. Python takes a different approach. You start with plain public attributes, and if you later need validation, computation, or logging when an attribute is accessed or modified, you upgrade to @property without changing a single line of calling code. This article covers the complete progression from bare attributes through explicit getter/setter methods to the @property decorator, explains how it works under the hood through the descriptor protocol, and shows you when a custom descriptor is the better tool.
The Problem @property Solves
Consider a class that starts with a simple public attribute:
class User:
def __init__(self, name, age):
self.name = name
self.age = age
u = User("Kandi", 30)
print(u.age)
u.age = -5 # no validation, accepts nonsense
print(u.age)
There is no validation. Anyone can assign a negative age. The traditional fix in other languages is to hide the attribute behind explicit getter and setter methods:
class User:
def __init__(self, name, age):
self._name = name
self.set_age(age)
def get_age(self):
return self._age
def set_age(self, value):
if not isinstance(value, int) or value < 0:
raise ValueError("Age must be a non-negative integer")
self._age = value
This works, but it breaks every line of code that was using u.age directly. Every caller now has to switch to u.get_age() and u.set_age(value). Python's @property solves this by letting you add the same validation logic while keeping the original u.age attribute-access syntax intact:
class User:
def __init__(self, name, age):
self.name = name
self.age = age # triggers the setter
@property
def age(self):
return self._age
@age.setter
def age(self, value):
if not isinstance(value, int) or value < 0:
raise ValueError("Age must be a non-negative integer")
self._age = value
u = User("Kandi", 30)
print(u.age) # calls the getter
u.age = 35 # calls the setter
print(u.age)
try:
u.age = -5 # setter raises ValueError
except ValueError as e:
print(e)
The calling code still uses u.age as if it were a plain attribute. The @property decorator intercepts the access and assignment behind the scenes.
You have a class with a plain public attribute radius. Six months later you need to reject negative values. Which approach preserves the existing calling code c.radius = 5 unchanged?
Anatomy of a Property: Getter, Setter, Deleter
A property can have up to three components. Each is a method with the same name, distinguished by its decorator:
| Decorator | Role | Triggered By |
|---|---|---|
@property | Getter | obj.attr |
@attr.setter | Setter | obj.attr = value |
@attr.deleter | Deleter | del obj.attr |
obj.attrobj.attr = valuedel obj.attrThe getter decorated with @property must be defined first. The setter and deleter decorators are derived from the getter's name. All three methods must share the same name:
class Product:
def __init__(self, name, price):
self._name = name
self.price = price
@property
def price(self):
"""The retail price in dollars."""
return self._price
@price.setter
def price(self, value):
if value < 0:
raise ValueError("Price cannot be negative")
self._price = round(value, 2)
@price.deleter
def price(self):
print(f"Clearing price for {self._name}")
del self._price
item = Product("Widget", 19.999)
print(item.price)
del item.price
The docstring goes on the getter method. Python propagates it to the property object, so help(Product.price) displays it correctly.
Given a property named price with all three components defined, which Python statement triggers the deleter?
Validation Inside the Setter
The setter is where validation logic belongs. Because self.price = value in __init__ triggers the setter, validation runs even during object construction:
class Temperature:
def __init__(self, celsius):
self.celsius = celsius
@property
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, value):
if not isinstance(value, (int, float)):
raise TypeError(f"Expected number, got {type(value).__name__}")
if value < -273.15:
raise ValueError("Temperature below absolute zero is not possible")
self._celsius = float(value)
@property
def fahrenheit(self):
return (self._celsius * 9 / 5) + 32
t = Temperature(100)
print(f"{t.celsius}C = {t.fahrenheit}F")
t.celsius = 0
print(f"{t.celsius}C = {t.fahrenheit}F")
try:
Temperature(-300)
except ValueError as e:
print(e)
The Temperature(-300) call fails inside __init__ because self.celsius = celsius routes through the setter, which rejects the value before _celsius is ever stored.
Adding Type Hints to Properties
Type hints work with properties, but the placement is specific. The return type goes on the getter; the value parameter type goes on the setter. There is no class-level annotation for the property itself — that would shadow the property object in the class namespace:
class BankAccount:
def __init__(self, balance: float) -> None:
self.balance = balance
@property
def balance(self) -> float: # return type on the getter
return self._balance
@balance.setter
def balance(self, value: float) -> None: # value type on the setter
if value < 0:
raise ValueError("Balance cannot be negative")
self._balance = value
Type checkers like mypy and pyright understand this convention. They infer the property's type from the getter's return annotation and check that the setter's value parameter type is compatible. Do not add a class-level annotation like balance: float above the getter — it will shadow the property object, and the getter and setter will stop working.
Given this class, what happens when you call Temperature(-300)?
Read-Only Properties
If you define a getter without a setter, the attribute becomes read-only. Any assignment raises AttributeError:
class Point:
def __init__(self, x, y):
self._x = x
self._y = y
@property
def x(self):
return self._x
@property
def y(self):
return self._y
def __repr__(self):
return f"Point({self._x}, {self._y})"
p = Point(3, 7)
print(p.x, p.y)
try:
p.x = 10
except AttributeError as e:
print(f"Blocked: {e}")
This pattern is useful for immutable value objects where coordinates, IDs, or timestamps should never change after initialization.
A Point class defines x and y as read-only properties with no setter. What happens when you run p.x = 10?
Computed Properties and cached_property
Properties do not need a backing _attribute at all. A computed property calculates its value from other attributes on each access:
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
@property
def area(self):
return self.width * self.height
@property
def perimeter(self):
return 2 * (self.width + self.height)
@property
def is_square(self):
return self.width == self.height
r = Rectangle(5, 10)
print(f"Area: {r.area}")
print(f"Perimeter: {r.perimeter}")
print(f"Square: {r.is_square}")
r.width = 10
print(f"Area after resize: {r.area}")
print(f"Square now: {r.is_square}")
Computed properties recalculate on every access. If the computation is expensive, functools.cached_property (added in Python 3.8) computes the value once on first access and stores it directly in the instance's __dict__ — subsequent reads return the cached value instantly without calling the function again.
Two important caveats verified in the official Python docs: cached_property requires a writable instance __dict__, so it does not work with __slots__ classes. The standard 3.8 implementation is also not thread-safe — if multiple threads access an uncached property simultaneously, the computation may run more than once.
from functools import cached_property
import statistics
class DataSet:
def __init__(self, numbers):
self._data = numbers
@cached_property
def variance(self):
# Computed once; result stored in instance.__dict__
print("computing variance...")
return statistics.variance(self._data)
ds = DataSet([1, 2, 3, 4, 5])
print(ds.variance) # triggers computation
print(ds.variance) # reads from __dict__, no recomputation
To invalidate the cache manually, use del ds.variance. The next access recomputes and caches the fresh value. This is the mechanism Django's cached_property has used since 2010, and it was standardized into Python's functools module in 3.8 based on the pattern's proven value in production frameworks.
Pattern: Multi-Unit Conversion via Shared Backing Value
One backing attribute, multiple computed views. This is more robust than separate _celsius and _fahrenheit attributes that can drift out of sync — there is a single source of truth, and every unit property derives from it:
class Temperature:
"""Single backing value in Kelvin. All units are computed views."""
def __init__(self, kelvin: float) -> None:
self.kelvin = kelvin
@property
def kelvin(self) -> float:
return self._kelvin
@kelvin.setter
def kelvin(self, value: float) -> None:
if value < 0:
raise ValueError(f"Kelvin cannot be negative, got {value}")
self._kelvin = float(value)
@property
def celsius(self) -> float:
return self._kelvin - 273.15
@celsius.setter
def celsius(self, value: float) -> None:
self.kelvin = value + 273.15 # routes through kelvin's validation
@property
def fahrenheit(self) -> float:
return self._kelvin * 9 / 5 - 459.67
@fahrenheit.setter
def fahrenheit(self, value: float) -> None:
self.kelvin = (value + 459.67) * 5 / 9
t = Temperature(373.15) # boiling point
print(f"{t.celsius:.2f}C {t.fahrenheit:.2f}F {t.kelvin:.2f}K")
t.fahrenheit = 32 # freezing — write in any unit
print(f"{t.celsius:.2f}C {t.fahrenheit:.2f}F {t.kelvin:.2f}K")
Validation lives once, in the Kelvin setter. Every other setter converts and delegates. Setting t.fahrenheit = -600 would fail inside the Kelvin setter — you get consistent enforcement regardless of which unit the caller uses. This pattern generalises to currency conversion, unit systems, or any domain with multiple representations of one quantity.
Pattern: Property as an Audit Log
Properties are not only for validation. Every access and assignment is a hook point. This pattern records every write to a managed attribute without the caller knowing anything has changed:
from datetime import datetime
class AuditedField:
"""Tracks every write to a managed attribute with a timestamp."""
def __set_name__(self, owner, name):
self._name = "_" + name
self._log_name = "_" + name + "_log"
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self._name, None)
def __set__(self, obj, value):
if not hasattr(obj, self._log_name):
setattr(obj, self._log_name, [])
getattr(obj, self._log_name).append({
"value": value,
"at": datetime.now().isoformat(timespec="seconds"),
})
setattr(obj, self._name, value)
class Contract:
status = AuditedField()
budget = AuditedField()
def __init__(self, status: str, budget: float) -> None:
self.status = status
self.budget = budget
def history(self, field: str):
return getattr(self, f"_{field}_log", [])
c = Contract("draft", 50_000)
c.status = "review"
c.budget = 62_000
c.status = "approved"
for entry in c.history("status"):
print(entry)
The AuditedField descriptor is reusable across any class that needs field-level audit trails — useful for financial records, configuration management, or anywhere you need to know not just the current value but every value it has ever held and when.
Pattern: Lazy Initialization with Manual Cache Control
functools.cached_property works when the cached value never needs to be invalidated. When it does — for example, a parsed configuration that must refresh when an underlying file changes — you need manual cache control. This pattern uses a property to provide lazy initialization with an explicit invalidation method:
import json
from pathlib import Path
class ConfigLoader:
def __init__(self, path: str) -> None:
self._path = Path(path)
self._cache = None # None signals "not yet loaded"
@property
def data(self) -> dict:
if self._cache is None:
print(f"Loading {self._path}...")
self._cache = json.loads(self._path.read_text())
return self._cache
def invalidate(self) -> None:
"""Force a reload on the next access."""
self._cache = None
# First access triggers load; subsequent accesses return the cache.
# cfg.invalidate() drops the cache; next access loads fresh from disk.
Unlike cached_property, this pattern works with __slots__, supports explicit cache invalidation, and gives you full control over when and how the value is refreshed. The tradeoff is that you manage the cache sentinel (None here) manually — use a dedicated sentinel object (_UNSET = object()) if None is a legitimate cached value.
Pattern: Cross-Property Validation
Sometimes the valid range of one attribute depends on another. Properties can coordinate with each other through the instance:
class DateRange:
def __init__(self, start: str, end: str) -> None:
# Order matters: set start first so end's setter can compare
self.start = start
self.end = end
@property
def start(self) -> str:
return self._start
@start.setter
def start(self, value: str) -> None:
if hasattr(self, "_end") and value > self._end:
raise ValueError(f"start {value!r} cannot be after end {self._end!r}")
self._start = value
@property
def end(self) -> str:
return self._end
@end.setter
def end(self, value: str) -> None:
if hasattr(self, "_start") and value < self._start:
raise ValueError(f"end {value!r} cannot be before start {self._start!r}")
self._end = value
r = DateRange("2026-01-01", "2026-12-31")
print(r.start, "to", r.end)
try:
r.end = "2025-06-01"
except ValueError as e:
print(e)
The hasattr guards handle the construction sequence: during __init__, start is set before end exists, so the end-setter skips the comparison on that first pass. Once both attributes are established, every subsequent assignment is validated against the other. This pattern is common in scheduling, date/time range selection, and financial period objects.
The Deleter Method
The deleter is less common but useful when removing an attribute should trigger cleanup, logging, or resetting to a default state:
class Config:
def __init__(self, theme="dark"):
self._theme = theme
@property
def theme(self):
return self._theme
@theme.setter
def theme(self, value):
allowed = ("dark", "light", "system")
if value not in allowed:
raise ValueError(f"Theme must be one of {allowed}")
self._theme = value
@theme.deleter
def theme(self):
print("Resetting theme to default")
self._theme = "dark"
cfg = Config("light")
print(cfg.theme)
del cfg.theme
print(cfg.theme)
In the Config deleter above, del cfg.theme does not actually remove the attribute — it resets it to "dark". What is the advantage of this pattern over letting the attribute truly disappear?
@property vs. property() Built-in
The @property decorator is syntactic sugar for the property() built-in function. These two class definitions are equivalent:
# Using the property() function directly
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
radius = property(_get_radius, _set_radius, doc="The circle radius.")
# Using the @property decorator (preferred)
class Circle:
def __init__(self, radius):
self._radius = radius
@property
def radius(self):
"""The circle radius."""
return self._radius
@radius.setter
def radius(self, value):
if value < 0:
raise ValueError("Radius cannot be negative")
self._radius = value
The decorator form is preferred because it keeps the getter, setter, and deleter grouped by name rather than spread across separate methods with different names. It is also the style recommended by PEP 8.
This class crashes at runtime. What is the bug?
Properties and Inheritance
Properties work with inheritance, but overriding them requires care. If a subclass needs to modify only the setter, it must redefine the entire property because the setter is part of the property object, not an independent method:
class Account:
def __init__(self, balance):
self.balance = balance
@property
def balance(self):
return self._balance
@balance.setter
def balance(self, value):
if value < 0:
raise ValueError("Balance cannot be negative")
self._balance = value
class PremiumAccount(Account):
@Account.balance.setter
def balance(self, value):
if value < -1000:
raise ValueError("Overdraft limit is -1000")
self._balance = value
standard = Account(500)
premium = PremiumAccount(500)
premium.balance = -500 # allowed for premium
print(f"Premium: {premium.balance}")
try:
standard.balance = -500 # blocked for standard
except ValueError as e:
print(f"Standard: {e}")
The @Account.balance.setter syntax creates a new property that inherits the getter from Account but replaces the setter with the one defined in PremiumAccount.
How @property Works: The Descriptor Protocol
Most articles treat @property as a black box. Understanding what it actually does explains several behaviors that otherwise seem arbitrary: why a read-only property raises AttributeError even though you never wrote a setter, why a property cannot be shadowed by an instance attribute of the same name, and why the getter must be defined before the setter.
The Python Descriptor HowTo Guide defines the protocol precisely: a descriptor is any object that defines __get__(), __set__(), or __delete__(). When Python resolves obj.attr, it does not simply look in obj.__dict__. Instead, object.__getattribute__() walks the class's method resolution order (MRO) first, checks whether any class holds a descriptor for that name, and applies a strict priority chain:
| Priority | What Python finds | Action |
|---|---|---|
| 1 (highest) | Data descriptor on the class or a parent | Calls descriptor.__get__(obj, type(obj)) |
| 2 | Entry in obj.__dict__ | Returns the value directly |
| 3 | Non-data descriptor on the class | Calls descriptor.__get__(obj, type(obj)) |
| 4 (lowest) | __getattr__, if defined | Called as a fallback |
__get__ and __set__)Calls
descriptor.__get__(obj, type(obj))obj.__dict__Returns the value directly from the instance dict
__get__)Calls
descriptor.__get__(obj, type(obj))__getattr__, if defined on the classCalled as a last-resort fallback only
A data descriptor defines both __get__ and __set__ (or __delete__). A non-data descriptor defines only __get__. The difference in priority between them is what makes cached_property work: it is a non-data descriptor, so after it writes the computed value into obj.__dict__ on first access, that instance dictionary entry wins on all subsequent lookups — no descriptor call needed.
property() is always a data descriptor. Even when you define no setter, the property object internally provides a __set__ that raises AttributeError. That is why read-only properties raise the error, and why you cannot accidentally shadow a property by assigning an instance attribute of the same name.
The official Python docs describe the pure Python equivalent of what property() does, implemented in C as PyProperty_Type in Objects/descrobject.c. The Python Software Foundation publishes this reference implementation in the Descriptor HowTo Guide under the Zero Clause BSD License. Here is the structure with annotations added for clarity:
# Annotated adaptation of the reference implementation from:
# Python Descriptor HowTo Guide — Python Software Foundation
# https://docs.python.org/3/howto/descriptor.html
# Licensed under the Zero Clause BSD License.
class Property:
def __init__(self, fget=None, fset=None, fdel=None, doc=None):
self.fget = fget # the getter function
self.fset = fset # the setter function (or None)
self.fdel = fdel # the deleter function (or None)
# Inherit docstring from the getter if none is provided explicitly
if doc is None and fget is not None:
doc = fget.__doc__
self.__doc__ = doc
def __get__(self, obj, objtype=None):
# Called on class access: return the property object itself
if obj is None:
return self
if self.fget is None:
raise AttributeError("unreadable attribute")
return self.fget(obj)
def __set__(self, obj, value):
# Even without a user-defined setter, this method exists —
# making Property a data descriptor that wins over instance __dict__
if self.fset is None:
raise AttributeError("can't set attribute")
self.fset(obj, value)
def __delete__(self, obj):
if self.fdel is None:
raise AttributeError("can't delete attribute")
self.fdel(obj)
def getter(self, fget):
# Returns a NEW property — does not mutate the existing one
return type(self)(fget, self.fset, self.fdel, self.__doc__)
def setter(self, fset):
# Returns a NEW property with fset attached
return type(self)(self.fget, fset, self.fdel, self.__doc__)
def deleter(self, fdel):
return type(self)(self.fget, self.fset, fdel, self.__doc__)
Two things jump out from this implementation. First, __get__ checks if obj is None and returns self (the property object) when accessed from the class rather than an instance — which is why MyClass.price gives you the property object, not a value. Second, setter() and deleter() return a new property object rather than modifying the existing one. Each @price.setter decoration creates a fresh property that carries over the existing getter while adding the new setter. This is why order matters: if you define the setter before the getter, price does not yet exist as a property object, so @price.setter raises a NameError.
Run type(MyClass.price) on any class with a property and you will see <class 'property'>. Run vars(MyClass) and you will find the property object stored in the class dictionary, not the instance dictionary. This confirms that the property lives at the class level and intercepts attribute access through the descriptor protocol for every instance.
Accessing a Property from the Class, Not an Instance
Look back at the __get__ implementation above: when obj is None, it returns self — the property object itself. This happens whenever you access the property through the class rather than an instance:
class Circle:
def __init__(self, radius):
self._radius = radius
@property
def radius(self):
"""The circle's radius."""
return self._radius
# Access from an instance — calls fget, returns the value
c = Circle(5)
print(c.radius) # 5
# Access from the class — returns the property object itself
print(Circle.radius) #
print(type(Circle.radius)) #
# Introspect the property object directly
print(Circle.radius.fget) #
print(Circle.radius.__doc__) # The circle's radius.
This is useful for testing and tooling. You can inspect MyClass.prop.fget, MyClass.prop.fset, and MyClass.prop.fdel directly to verify what functions back the property, or read MyClass.prop.__doc__ to confirm the docstring is propagated correctly. A setter-only check is also handy in tests: MyClass.prop.fset is None tells you definitively whether the property is read-only.
When to Use a Descriptor Instead of @property
@property is the right tool for per-attribute, per-class logic. A custom descriptor is the right tool when the same logic needs to run for multiple attributes across multiple classes. The Real Python guide on property() makes the practical test clear: once you notice yourself duplicating the same property pattern across your codebase, a descriptor is the better abstraction.
Here is the concrete situation. Suppose you need a non-negative integer constraint on three different attributes across two different classes. With @property you would write six methods (three getters, three setters) and repeat the same isinstance check in each setter. With a descriptor you write the validation once:
class NonNegativeInt:
"""Reusable data descriptor: enforces non-negative integer constraint."""
def __set_name__(self, owner, name):
# Python 3.6+: called automatically when the class is created.
# Stores the attribute name so the descriptor knows where to
# read/write the value in the instance's __dict__.
self._name = "_" + name
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self._name, 0)
def __set__(self, obj, value):
if not isinstance(value, int) or value < 0:
raise ValueError(
f"{self._name[1:]} must be a non-negative integer, got {value!r}"
)
setattr(obj, self._name, value)
class Inventory:
quantity = NonNegativeInt()
reorder_point = NonNegativeInt()
def __init__(self, quantity, reorder_point):
self.quantity = quantity
self.reorder_point = reorder_point
class Employee:
vacation_days = NonNegativeInt()
def __init__(self, name, vacation_days):
self.name = name
self.vacation_days = vacation_days
inv = Inventory(100, 20)
print(inv.quantity, inv.reorder_point)
emp = Employee("Kandi", 15)
print(emp.vacation_days)
try:
emp.vacation_days = -3
except ValueError as e:
print(e)
The __set_name__ hook (available since Python 3.6) means the descriptor automatically learns the attribute name it was assigned to during class creation. No manual naming required. The validation logic lives in one place and is reused across both Inventory and Employee without any duplication.
| Situation | Use |
|---|---|
| One or two managed attributes on a single class | @property |
| Validation or logic shared by many attributes or classes | Custom descriptor |
| Expensive computed value, computed once per instance | functools.cached_property |
| Computed value that may change as other attributes change | @property (recomputes each time) |
@property__get__, __set__, __set_name__)functools.cached_property — computes once, stores in instance __dict__@property (recomputes fresh on every access)You access ds.variance three times on the same instance. The first call prints computing variance... — the second and third do not. Which decorator was used, and why does the computation not run again?
Common Mistakes
Naming the Private Attribute the Same as the Property
# WRONG: causes infinite recursion
class Broken:
def __init__(self, value):
self.value = value
@property
def value(self):
return self.value # calls itself endlessly
@value.setter
def value(self, v):
self.value = v # calls itself endlessly
The getter returns self.value, which triggers the getter again, creating infinite recursion. The backing attribute must have a different name — by convention, a leading underscore: self._value.
Defining the Setter Before the Getter
The @property decorator must come first because it creates the property object. The setter decorator (@name.setter) attaches to an existing property. If the property does not exist yet, Python raises a NameError.
Using @property on Methods That Need Arguments
A property getter receives only self. It cannot accept additional arguments. If you need parameters, use a regular method instead of a property.
@property Inside a @dataclass
This is one of the most reliably surprising pitfalls for intermediate Python developers. If you add a @property to a @dataclass and keep the same name as a field, @dataclass will generate an __init__ that tries to assign directly to the property name — which bypasses the descriptor entirely and usually sets an instance attribute that shadows it. The safe pattern is to exclude the managed attribute from the generated __init__ using field(init=False) and initialize it in __post_init__:
from dataclasses import dataclass, field
@dataclass
class Product:
name: str
_price: float = field(init=False, repr=False)
price_input: float = field(default=0.0) # receives the value from __init__
def __post_init__(self):
self.price = self.price_input # routes through the setter
@property
def price(self) -> float:
return self._price
@price.setter
def price(self, value: float) -> None:
if value < 0:
raise ValueError("Price cannot be negative")
self._price = round(value, 2)
p = Product(name="Widget", price_input=19.99)
print(p.price) # 19.99
try:
p.price = -5
except ValueError as e:
print(e)
For most cases where you want validation in a dataclass, __post_init__ with direct attribute assignment (and a leading-underscore private field) is simpler than fighting the interaction between @dataclass and @property. If the validation needs to run on reassignment too, the property approach above is the correct one.
Chaining @classmethod and @property
The combination of @classmethod and @property has a troubled history. Before Python 3.9, stacking them produced undefined or silently wrong behavior. Python 3.9 added explicit support. Python 3.11 deprecated it again — the official position is that @classmethod should not be stacked with @property at all. If you need a class-level managed attribute, the correct approach is a custom descriptor on the metaclass, which is an advanced pattern outside the scope of this article. For practical purposes: do not stack @classmethod and @property in code that needs to run on Python 3.11+.
If your getter and setter do nothing beyond reading and writing the attribute, skip @property entirely and use a plain public attribute. Properties add complexity. Use them only when access or assignment needs validation, computation, logging, or other side effects.
You add a @property named price to a @dataclass. The dataclass also has a field called price. What does @dataclass generate that creates a conflict?
How to Add @property to a Python Class: Step-by-Step
- Start with a plain public attribute. Python's convention is to begin with public attributes. You can always add
@propertylater without breaking existing code that accesses the attribute with dot notation. - Define the getter with
@property. Decorate the first method with@property. Use a private backing attribute (e.g.,self._name) to store the value and avoid infinite recursion. The setter and deleter decorators (@name.setter,@name.deleter) attach to the property object the getter creates — if the getter is not defined first, Python raises aNameError. - Add validation in the setter. Because
self.attr = valuein__init__triggers the setter, validation logic protects the attribute from the moment the object is created. - Omit the setter to make the attribute read-only. A property with only a getter raises
AttributeErroron assignment. It still creates a data descriptor internally, so the property cannot be shadowed by an instance attribute of the same name. - Use a getter-only property for computed values. Computed properties calculate from other attributes on every access and need no backing attribute. Use
functools.cached_propertyif the computation is expensive and the inputs do not change — but note it requires a writable instance__dict__and is not thread-safe by default. - Override properties in subclasses with
@ParentClass.attr.setter. Each decoration returns a new property object, selectively replacing one component while inheriting the others. - Switch to a custom descriptor when the same logic repeats. If the same validation or transformation is needed on multiple attributes across multiple classes, consolidate it into a descriptor class with
__get__,__set__, and optionally__set_name__.
Key Takeaways
- Start with plain attributes: Python's convention is to begin with public attributes. You can always add
@propertylater without breaking existing code that accesses the attribute with dot notation. - The getter must come first: Decorate the first method with
@property. The setter and deleter decorators (@name.setter,@name.deleter) attach to the property object that the getter creates. If the getter has not been defined yet, Python raises aNameError. - All three methods share the same name: The getter, setter, and deleter are all named after the property. The backing attribute uses a different name, typically with a leading underscore (
_name) to avoid infinite recursion. - Validation runs in the setter, including during construction: Because
self.attr = valuein__init__triggers the setter, validation logic protects the attribute from the moment the object is created. - Read-only attributes omit the setter: A property with only a getter raises
AttributeErroron assignment. It still creates a data descriptor internally, so the property cannot be shadowed by an instance attribute of the same name. - Computed properties have no backing attribute: They calculate their value from other attributes on every access. Use
functools.cached_propertyif the computation is expensive and the underlying inputs do not change — but note it requires a writable instance__dict__and is not thread-safe by default. - @property is a data descriptor: Under the hood,
property()implements__get__,__set__, and__delete__. This is why properties take priority over instance dictionary entries in Python's attribute lookup chain.cached_propertyis a non-data descriptor (only__get__), which is exactly why the cached instance attribute can shadow it after the first access. - Overriding properties in subclasses: Use
@ParentClass.attr.setteror@ParentClass.attr.getterto selectively override one component while inheriting the others. Each decoration returns a new property object. - Switch to a custom descriptor when logic repeats: If the same validation or transformation is needed on multiple attributes across multiple classes, consolidate it into a descriptor class with
__get__,__set__, and optionally__set_name__. - Type hints belong on the methods, not the class body: Annotate the getter's return type and the setter's value parameter. A class-level annotation with the same name as the property will shadow the property object and break it.
- @property and @dataclass require careful coordination: The
@dataclass-generated__init__assigns directly to field names. Usefield(init=False)for the private backing attribute and route through the setter in__post_init__to keep validation intact. - Do not stack @classmethod with @property: This combination was deprecated in Python 3.11. If you need a class-level managed attribute, use a descriptor on the metaclass instead.
The @property decorator is the standard Python mechanism for turning methods into managed attributes. It keeps your class interface clean, your validation centralized, and your calling code unchanged. When the same logic repeats across many attributes or classes, the descriptor protocol it is built on gives you the tools to factor that logic into a single reusable place.
Frequently Asked Questions
What does the @property decorator do in Python?
The @property decorator turns a method into a managed attribute backed by the descriptor protocol. When you access the attribute, Python calls the getter method behind the scenes. Combined with @name.setter and @name.deleter, it lets you add validation, computation, or side effects to attribute access and assignment while keeping clean dot-notation syntax. Because property() creates a data descriptor, it takes priority over the instance dictionary in Python's attribute lookup chain.
How do I create a setter with @property?
First define the getter method decorated with @property. Then define a second method with the same name, decorated with @name.setter, where name matches the getter. The setter method receives self and the new value as parameters. Python calls this method automatically when you assign to the attribute. Because self.attr = value in __init__ also routes through the setter, validation logic runs even during object construction.
Can I make a read-only attribute with @property?
Yes. Define only the getter method with @property and omit the setter. Any attempt to assign a value to the attribute will raise an AttributeError with the message property 'x' of 'ClassName' object has no setter. Even without a setter, property() provides an internal __set__ that raises the error, which also prevents the property from being shadowed by an instance attribute of the same name.
What is the difference between property() and @property?
They are functionally identical. The property() built-in function takes getter, setter, and deleter functions as arguments and returns a property object. The @property decorator is syntactic sugar that achieves the same result with cleaner syntax by decorating methods directly in the class body. The decorator form is preferred because it keeps getter, setter, and deleter grouped by name and is the style recommended by PEP 8.
What is the descriptor protocol and how does @property use it?
The descriptor protocol is the mechanism Python uses for all attribute access. A descriptor is any object that defines __get__(), __set__(), or __delete__(). When Python resolves obj.attr, it checks whether the class holds a data descriptor (one defining both __get__ and __set__) for that name before consulting the instance dictionary. property() is a built-in data descriptor: accessing obj.age calls Property.__get__(obj, type(obj)), which invokes the fget function you defined. This priority is why properties cannot be accidentally shadowed by instance attributes of the same name.
What is functools.cached_property and how is it different from @property?
functools.cached_property, added in Python 3.8, computes the property value once on first access and stores it directly in the instance's __dict__. Subsequent accesses return the cached value without calling the function again. Unlike @property, cached_property is a non-data descriptor (only __get__, no __set__), so the instance dictionary entry it creates on first access takes priority on future lookups. Important limitations: it does not work with __slots__ classes, and the standard 3.8 implementation is not thread-safe.
When should I use a custom descriptor instead of @property?
Use a custom descriptor when the same validation or access logic needs to be shared across multiple attributes or multiple classes. If you find yourself copying and pasting the same @property getter and setter into several classes, consolidate that logic into a descriptor class implementing __get__, __set__, and __set_name__. The __set_name__ hook, available since Python 3.6, means the descriptor automatically learns the attribute name assigned to it at class creation time, so no manual configuration is needed.
How do I add type hints to a property?
Place the return type annotation on the getter method and the value type annotation on the setter's value parameter. Do not add a class-level annotation with the same name as the property — that would shadow the property object in the class namespace and break the getter and setter. Type checkers like mypy and pyright understand the getter/setter annotation convention and will type-check assignments to the property accordingly.
Does @property work inside a @dataclass?
Not straightforwardly. The @dataclass decorator generates an __init__ that assigns directly to field names, which can bypass or conflict with a property of the same name. The reliable pattern is to mark the private backing field with field(init=False) to exclude it from the generated __init__, accept the raw value under a different parameter name, and then route through the property setter in __post_init__.
What does accessing a property on the class (not an instance) return?
It returns the property object itself, not a value. This is because the property's __get__ method checks whether it was called from an instance or from the class: when called from the class, obj is None, and __get__ returns self (the property object). You can use this to introspect the property: MyClass.prop.fget gives the getter function, MyClass.prop.fset gives the setter (or None if read-only), and MyClass.prop.__doc__ gives the docstring.