Understanding Python Properties: A Practical Guide to @property

Blog / Python · August 5, 2023 · Updated June 10, 2026 · 10 min read
Understanding Python Properties: A Practical Guide to @property

Python's @property decorator lets you expose a method as if it were a plain attribute. You write obj.value (no parentheses), but behind that simple access Python runs your getter, setter, or deleter code. Use it when you need a computed attribute (a value derived from other data), validation on assignment, lazy or read-only access, or when you want to add logic to an existing attribute without changing the public API that callers already depend on.

The key win is that @property keeps the simple obj.value syntax while giving you full control. You start with a plain attribute, and the day you need validation or computation, you convert it to a property and no calling code has to change. That is why seasoned Python developers reach for properties instead of writing Java-style get_value() / set_value() methods.

At MicroPyramid we have spent 12+ years and 50+ projects building Python and Django applications, and clean attribute design is one of the small decisions that keeps a codebase maintainable as it grows. This guide walks through every part of the property protocol with current Python 3.11+ examples.

The problem: getters and setters are un-Pythonic

If you come from Java or C++, your instinct is to make attributes "private" and wrap them in accessor methods:

# Don't do this in Python
class Circle:
    def __init__(self, radius):
        self._radius = radius

    def get_radius(self):
        return self._radius

    def set_radius(self, value):
        self._radius = value


c = Circle(5)
print(c.get_radius())   # verbose
c.set_radius(10)        # verbose

This forces every caller to use get_radius() and set_radius() for no reason. The Pythonic approach is the opposite: start with a plain public attribute, and only add logic later if you actually need it.

class Circle:
    def __init__(self, radius):
        self.radius = radius   # just a plain attribute


c = Circle(5)
print(c.radius)   # 5
c.radius = 10     # works, no boilerplate

When you later need validation or a computed value, @property lets you intercept attribute access while keeping that exact c.radius syntax. Callers never know the difference.

The @property getter

Decorate a method with @property and it becomes readable as an attribute. By convention the underlying data lives in a "private" attribute prefixed with a single underscore (_radius):

class Circle:
    def __init__(self, radius):
        self._radius = radius

    @property
    def radius(self):
        """The circle's radius."""
        return self._radius


c = Circle(5)
print(c.radius)   # 5  -> calls the getter, no parentheses
c.radius = 10     # AttributeError: property 'radius' has no setter

Right now radius is read-only: accessing it works, but assignment raises AttributeError because we have not defined a setter yet. That is already useful for values that should never change after construction. The docstring on the getter becomes the property's __doc__, so help(Circle.radius) works as expected.

Adding a setter with validation

To make the property writable, define a second method with the same name decorated with @<name>.setter. This is the single most common reason to use a property: validate or normalise a value before storing it.

class Circle:
    def __init__(self, radius):
        self.radius = radius   # goes through the setter -> validates on construction too

    @property
    def radius(self):
        return self._radius

    @radius.setter
    def radius(self, value):
        if not isinstance(value, (int, float)):
            raise TypeError("radius must be a number")
        if value < 0:
            raise ValueError("radius cannot be negative")
        self._radius = value


c = Circle(5)
c.radius = 10        # OK
c.radius = -1        # ValueError: radius cannot be negative
c.radius = "big"     # TypeError: radius must be a number

Notice that __init__ assigns to self.radius (the public name), not self._radius. This means the validation runs even during construction, so you can never create an invalid object. The setter stores the validated value in the private _radius to avoid infinite recursion (more on that pitfall below).

The deleter

The third piece of the protocol is @<name>.deleter, which runs when someone calls del obj.attr. It is the least-used part, but handy when deleting an attribute should also clean up related state:

class Circle:
    def __init__(self, radius):
        self._radius = radius

    @property
    def radius(self):
        return self._radius

    @radius.setter
    def radius(self, value):
        self._radius = value

    @radius.deleter
    def radius(self):
        print("Deleting radius")
        del self._radius


c = Circle(5)
del c.radius   # prints "Deleting radius", removes the underlying value

The functional property() form

The decorator syntax is just sugar over the built-in property() callable. Its full signature is property(fget=None, fset=None, fdel=None, doc=None). You can build a property explicitly, which is occasionally useful when you already have standalone functions or want to attach getters/setters programmatically:

class Circle:
    def __init__(self, radius):
        self.radius = radius

    def _get_radius(self):
        return self._radius

    def _set_radius(self, value):
        if value < 0:
            raise ValueError("radius cannot be negative")
        self._radius = value

    def _del_radius(self):
        del self._radius

    radius = property(_get_radius, _set_radius, _del_radius, "The circle's radius.")


c = Circle(5)
print(c.radius)   # 5

The decorator form (@property, @radius.setter) is preferred in modern code because it reads more clearly and keeps each accessor next to its name. The functional form is the same machinery underneath — every @x.setter call simply returns a new property object built from the old one plus the new setter function.

Read-only and computed properties

A property with only a getter is the idiomatic way to expose derived values that should be calculated on demand rather than stored. The classic example is converting between units, or any value computed from other attributes:

class Celsius:
    def __init__(self, temperature=0):
        self.temperature = temperature   # validated via the setter

    @property
    def temperature(self):
        return self._temperature

    @temperature.setter
    def temperature(self, value):
        if value < -273.15:
            raise ValueError("Temperature below absolute zero is not possible")
        self._temperature = value

    @property
    def fahrenheit(self):
        # computed on every access, always in sync with temperature
        return (self._temperature * 1.8) + 32


c = Celsius(25)
print(c.fahrenheit)   # 77.0
c.temperature = 30
print(c.fahrenheit)   # 86.0  -> recomputed, never stale

fahrenheit is computed fresh on every access, so it can never drift out of sync with temperature. There is no _fahrenheit field to keep updated. This is the core benefit of computed properties: one source of truth, derived views stay correct automatically.

Caching expensive properties with functools.cached_property

Computing on every access is wasteful when the value is expensive and does not change. Since Python 3.8 the standard library ships functools.cached_property: it computes the value the first time, stores it in the instance __dict__ under the same name, and returns the cached result on every subsequent access. The method body never runs again.

from functools import cached_property


class Dataset:
    def __init__(self, rows):
        self.rows = rows

    @cached_property
    def summary(self):
        print("Computing summary (expensive)...")
        return {
            "count": len(self.rows),
            "total": sum(self.rows),
        }


d = Dataset([1, 2, 3, 4])
print(d.summary)   # prints "Computing summary..." then the dict
print(d.summary)   # cached -> no recompute, just returns the stored dict

# Invalidate the cache by deleting the cached attribute:
del d.summary
print(d.summary)   # recomputes once more

Two things to know about cached_property:

  • It is read-write by default because it shadows itself with a plain instance attribute. After the first access, d.__dict__['summary'] exists and is returned directly — you can even assign to it or del it to invalidate the cache.
  • It needs a writable instance __dict__, so it does not work on classes that define __slots__ (unless __dict__ is in the slots). A regular @property works fine with __slots__.

Use @property for cheap, always-fresh derived values; use @cached_property for expensive values that stay constant for the object's lifetime (parsed config, aggregate statistics, a compiled regex).

Properties vs attributes vs slots: when to use each

Properties are not free — each access is a method call, and they add indirection. Reach for the simplest tool that solves the problem. The table below summarises the trade-offs:

Mechanism Stored where Recomputes? Validation/logic Best for
Plain attribute instance __dict__ n/a none simple mutable data with no rules
@property backing _field you manage every access yes (get/set/del) validation, read-only, derived values
functools.cached_property instance __dict__ (self-shadowing) once, then cached on first access only expensive values that don't change
Custom descriptor up to the descriptor up to you reusable across many attrs/classes the same logic on many attributes
__slots__ fixed slot, no __dict__ n/a none memory savings on many small objects

Rule of thumb: start with a plain attribute. Switch to @property when you need validation, read-only access, or a computed value. Use cached_property when that computed value is expensive and stable. Reach for a full descriptor only when you need to share the same attribute logic across many fields or classes.

How properties relate to the descriptor protocol

Under the hood, property is itself a descriptor — an object that defines __get__, __set__, and/or __delete__ and lives on the class. When you access instance.attr, Python finds the property descriptor on the class and calls its __get__; assignment calls __set__; del calls __delete__. That is the entire mechanism behind the magic.

Knowing this matters when you need to apply the same logic to many attributes — writing one getter/setter pair per field gets repetitive. A reusable descriptor solves that:

class Positive:
    """A reusable descriptor: any attribute using it must be a positive number."""

    def __set_name__(self, owner, name):
        # Python 3.6+ tells the descriptor the attribute name it was assigned to
        self.private_name = "_" + name

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return getattr(obj, self.private_name)

    def __set__(self, obj, value):
        if value <= 0:
            raise ValueError(f"{self.private_name[1:]} must be positive")
        setattr(obj, self.private_name, value)


class Rectangle:
    width = Positive()
    height = Positive()

    def __init__(self, width, height):
        self.width = width
        self.height = height


r = Rectangle(4, 5)
r.width = -1   # ValueError: width must be positive

A property is the simplest specialised descriptor; a custom descriptor class is the general case. If you find yourself copying the same property getter/setter across many attributes, that is the signal to write a descriptor instead.

Common pitfalls

1. Infinite recursion in the setter. The most common property bug is assigning to the public name inside the setter instead of the private backing field:

class Broken:
    @property
    def value(self):
        return self._value

    @value.setter
    def value(self, v):
        self.value = v   # BUG: calls the setter again -> RecursionError

The assignment self.value = v re-triggers the setter, which assigns again, forever. Always store into a different private attribute (self._value = v).

2. Properties live on the class, not the instance. A property only works when defined on the class. Assigning a property() object to self in __init__ does nothing useful — Python only invokes the descriptor protocol for attributes found on the type. (In old Python 2, properties also silently failed on old-style classes; in Python 3 all classes are new-style, so this is no longer a concern.)

3. Setting the public name in __init__. When __init__ does self.radius = radius, it correctly routes through the setter and validates. That is intentional and good — just make sure the setter writes to self._radius, not self.radius.

4. cached_property plus __slots__. As noted above, cached_property needs an instance __dict__, so it breaks on slotted classes. Use a plain @property (recomputing each time) or add __dict__ to __slots__.

Frequently Asked Questions

When should I use @property vs a plain attribute?

Use a plain attribute by default — it is the simplest and fastest option. Switch to @property only when you need to add behaviour to attribute access: validating or normalising a value on assignment, exposing a read-only value, or returning a computed value derived from other data. Because a property keeps the exact obj.attr syntax, you can start with a plain attribute and convert it to a property later without changing any calling code.

What is the difference between @property and @cached_property?

A @property getter runs on every access, so the value is always fresh — ideal for cheap, derived values that must stay in sync. functools.cached_property runs the method only the first time, stores the result in the instance __dict__, and returns that cached value thereafter, so the body never runs again. Use cached_property for expensive values that do not change for the object's lifetime, and invalidate it with del obj.attr when you need a recompute.

Why is my property setter causing infinite recursion?

Because the setter is assigning to the public property name instead of a private backing field. If your setter does self.value = v, that assignment calls the setter again, which calls it again, until you hit RecursionError. Store the value in a different attribute, conventionally one prefixed with an underscore: self._value = v. The getter then reads back from self._value.

Can I create a write-only property?

Yes. Define a setter (and optionally a deleter) but make the getter raise AttributeError, for example raise AttributeError("this attribute is write-only"). Write-only properties are rare — they are occasionally used for things like a password field you can set but never read back — but the property protocol fully supports them.

Do properties work with slots?

A regular @property works perfectly with __slots__: the property descriptor lives on the class and stores data in a declared slot such as _value. functools.cached_property, however, needs a writable instance __dict__ to store its cache, so it does not work on a slotted class unless you also add '__dict__' to __slots__, which defeats much of the memory benefit of slots.

Are properties slower than plain attributes?

Yes, slightly — a property access is a Python-level method call, while a plain attribute is a direct dictionary lookup, so properties carry a small overhead. For almost all application code this is irrelevant. Only in extremely hot loops over millions of accesses would it matter, in which case you can read the underlying _field directly or cache the value. Do not avoid properties for premature performance reasons; reach for them whenever they make the code clearer or safer.

Share this article