How to Use @property Decorator for Getters and Setters

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)
Output 30 -5

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)
Output 30 35 Age must be a non-negative integer

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.

Python Pop Quiz

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:

DecoratorRoleTriggered By
@propertyGetterobj.attr
@attr.setterSetterobj.attr = value
@attr.deleterDeleterdel obj.attr
Triggered byAccessing the attribute: obj.attr
Triggered byAssignment: obj.attr = value
Triggered byDeletion: del obj.attr

The 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
Output 20.0 Clearing price for Widget
Note

The docstring goes on the getter method. Python propagates it to the property object, so help(Product.price) displays it correctly.

Python Pop Quiz

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)
Output 100.0C = 212.0F 0.0C = 32.0F Temperature below absolute zero is not possible

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
Note

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.

Check Your Understanding

Given this class, what happens when you call Temperature(-300)?

class Temperature: def __init__(self, celsius): self.celsius = celsius # <-- note: not self._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 = float(value)

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}")
Output 3 7 Blocked: property 'x' of 'Point' object has no setter

This pattern is useful for immutable value objects where coordinates, IDs, or timestamps should never change after initialization.

Python Pop Quiz

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}")
Output Area: 50 Perimeter: 30 Square: False Area after resize: 100 Square now: True
Pro Tip: functools.cached_property

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
Output computing variance... 2.5 2.5

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")
Output 100.00C 212.00F 373.15K 0.00C 32.00F 273.15K

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)
Output {'value': 'draft', 'at': '2026-03-30T14:00:00'} {'value': 'review', 'at': '2026-03-30T14:00:01'} {'value': 'approved', 'at': '2026-03-30T14:00:02'}

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)
Output 2026-01-01 to 2026-12-31 end '2025-06-01' cannot be before start '2026-01-01'

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)
Output light Resetting theme to default dark
Python Pop Quiz

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.

Spot the Bug

This class crashes at runtime. What is the bug?

class Sensor: def __init__(self, reading): self.reading = reading @property def reading(self): return self.reading # line 7 @reading.setter def reading(self, value): if not isinstance(value, (int, float)): raise TypeError("Reading must be numeric") self._reading = float(value)

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}")
Output Premium: -500 Standard: Balance cannot be negative

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:

PriorityWhat Python findsAction
1 (highest)Data descriptor on the class or a parentCalls descriptor.__get__(obj, type(obj))
2Entry in obj.__dict__Returns the value directly
3Non-data descriptor on the classCalls descriptor.__get__(obj, type(obj))
4 (lowest)__getattr__, if definedCalled as a fallback
What Python findsA data descriptor on the class or a parent class (defines both __get__ and __set__)
ActionCalls descriptor.__get__(obj, type(obj))
What Python findsAn entry in obj.__dict__
ActionReturns the value directly from the instance dict
What Python findsA non-data descriptor on the class (defines only __get__)
ActionCalls descriptor.__get__(obj, type(obj))
What Python finds__getattr__, if defined on the class
ActionCalled 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.

Verifying this yourself

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.
Output 5 <property object at 0x7f...> <class 'property'> <function Circle.radius at 0x7f...> 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)
Output 100 20 15 vacation_days must be a non-negative integer, got -3

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.

SituationUse
One or two managed attributes on a single class@property
Validation or logic shared by many attributes or classesCustom descriptor
Expensive computed value, computed once per instancefunctools.cached_property
Computed value that may change as other attributes change@property (recomputes each time)
Recommended tool@property
Recommended toolCustom descriptor (implements __get__, __set__, __set_name__)
Recommended toolfunctools.cached_property — computes once, stores in instance __dict__
Recommended tool@property (recomputes fresh on every access)
Check Your Understanding

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?

from functools import cached_property import statistics class DataSet: def __init__(self, numbers): self._data = numbers @cached_property def variance(self): print("computing variance...") return statistics.variance(self._data) ds = DataSet([1, 2, 3, 4, 5]) print(ds.variance) print(ds.variance) print(ds.variance)

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)
Output 19.99 Price cannot be negative

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+.

When Not to Use @property

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.

Python Pop Quiz

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

  1. Start with a plain public attribute. Python's convention is to begin with public attributes. You can always add @property later without breaking existing code that accesses the attribute with dot notation.
  2. 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 a NameError.
  3. Add validation in the setter. Because self.attr = value in __init__ triggers the setter, validation logic protects the attribute from the moment the object is created.
  4. Omit the setter to make the attribute read-only. A property with only a getter raises AttributeError on assignment. It still creates a data descriptor internally, so the property cannot be shadowed by an instance attribute of the same name.
  5. 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_property if 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.
  6. Override properties in subclasses with @ParentClass.attr.setter. Each decoration returns a new property object, selectively replacing one component while inheriting the others.
  7. 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

  1. Start with plain attributes: Python's convention is to begin with public attributes. You can always add @property later without breaking existing code that accesses the attribute with dot notation.
  2. 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 a NameError.
  3. 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.
  4. Validation runs in the setter, including during construction: Because self.attr = value in __init__ triggers the setter, validation logic protects the attribute from the moment the object is created.
  5. Read-only attributes omit the setter: A property with only a getter raises AttributeError on assignment. It still creates a data descriptor internally, so the property cannot be shadowed by an instance attribute of the same name.
  6. Computed properties have no backing attribute: They calculate their value from other attributes on every access. Use functools.cached_property if 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.
  7. @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_property is a non-data descriptor (only __get__), which is exactly why the cached instance attribute can shadow it after the first access.
  8. Overriding properties in subclasses: Use @ParentClass.attr.setter or @ParentClass.attr.getter to selectively override one component while inheriting the others. Each decoration returns a new property object.
  9. 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__.
  10. 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.
  11. @property and @dataclass require careful coordination: The @dataclass-generated __init__ assigns directly to field names. Use field(init=False) for the private backing attribute and route through the setter in __post_init__ to keep validation intact.
  12. 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.