Python code often starts with plain public attributes. That is usually a good default: order.total is simpler than a pair of trivial getter and setter methods when reading and writing the value needs no extra behavior.

Requirements can change. A value may need validation, an attribute may become computed, or an existing public field may need to keep its interface while its internal representation changes. Python’s built-in property type lets a class place method logic behind normal attribute access.

A property is most useful when attribute syntax still describes the operation honestly. It should make an object’s interface easier to evolve, not hide expensive work or surprising side effects behind an innocent-looking lookup.

Start with a plain attribute when it is enough

A simple data-bearing class does not need a property for every field:

class User:
    def __init__(self, name):
        self.name = name

Callers can read and update name directly:

user = User("Ada")
print(user.name)
user.name = "Grace"

Adding methods such as get_name() and set_name() merely to wrap those operations usually adds ceremony without adding a useful contract.

The important design advantage is that Python lets a public attribute become a property later while callers can continue using attribute syntax. That makes plain attributes a reasonable starting point instead of a commitment that prevents future validation or computation.

Add validation with a property setter

Suppose a temperature object initially stores a public Celsius value, but the class later needs to reject values below absolute zero. A property can enforce the invariant at assignment time:

class Temperature:
    def __init__(self, celsius):
        self.celsius = celsius

    @property
    def celsius(self):
        return self._celsius

    @celsius.setter
    def celsius(self, value):
        value = float(value)
        if value < -273.15:
            raise ValueError("temperature is below absolute zero")
        self._celsius = value

Construction deliberately assigns through self.celsius rather than writing _celsius directly. That means initialization and later assignment use the same validation path.

temperature = Temperature(20)
temperature.celsius = "25"

print(temperature.celsius)

The stored value is 25.0 because the setter normalizes values with float() before storing them.

Keep a separate backing attribute

Inside the getter and setter, the implementation uses _celsius rather than celsius:

@property
def celsius(self):
    return self._celsius

Writing self.celsius inside the celsius setter would call the same setter again and recurse until Python raises RecursionError.

The leading underscore is a naming convention that signals an implementation detail. It does not make the attribute inaccessible. Callers can still reach _celsius, so the class should not treat the underscore as a security boundary.

Expose computed values as read-only properties

A property does not need a setter. This is useful for a value derived from other object state:

import math


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

    @property
    def area(self):
        return math.pi * self.radius ** 2

Callers use the result like an attribute:

circle = Circle(2)
print(circle.area)

There is no area setter, so an assignment such as circle.area = 10 raises AttributeError under normal attribute handling.

The area is computed from the current radius every time it is read. That is appropriate here because the calculation is cheap and the result should always reflect the current state.

Read-only does not mean immutable

A property without a setter prevents assignment through that property name, but it does not make the object immutable.

In the Circle example, callers can still change radius, and area will change accordingly. They may also be able to mutate other internal state depending on the class design.

Use a read-only property to express that a particular value is derived or not independently assignable. Use a broader immutability design when the entire object must not change.

Preserve an existing attribute interface

One practical reason to use property is to evolve a class without forcing every caller to switch from attribute syntax to method calls.

Imagine an early version:

class Account:
    def __init__(self, balance):
        self.balance = balance

Callers already use:

account.balance
account.balance = 100

If balance validation becomes necessary, the class can change internally:

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

The public spelling remains account.balance.

This compatibility has limits. Adding validation can cause assignments that previously succeeded to raise exceptions, and changing the accepted types or meaning of a value is still a behavioral API change. A property preserves access syntax; it does not make every semantic change backward compatible.

Add a deleter only when deletion has clear meaning

Properties can define deletion behavior with @name.deleter:

class Session:
    def __init__(self, token):
        self._token = token

    @property
    def token(self):
        return self._token

    @token.deleter
    def token(self):
        del self._token

Then del session.token invokes the deleter.

This feature is less commonly needed than getters and setters. Do not add a deleter merely because the syntax exists. Deleting an attribute should have a clear domain meaning and leave the object in a valid, documented state.

In the example above, reading session.token after deletion raises AttributeError because _token no longer exists. A real session type might instead model revocation with explicit state or a revoke() method if that operation carries important semantics beyond removing a stored value.

Use a method when the operation is not attribute-like

Attribute access communicates expectations. Developers generally expect it to be reasonably cheap, local, and free of surprising externally visible side effects.

A property that performs a network request makes this code misleading:

result = service.status

The line looks like an ordinary state lookup even if it can block on DNS, connect to another service, time out, or fail because the network is unavailable.

Prefer a method when the operation represents work:

result = service.fetch_status()

The method name makes an action visible to the caller.

The same reasoning applies to database queries, filesystem scans, sending messages, starting processes, or other substantial I/O. property is a syntax tool, not a reason to disguise commands as data.

Be cautious with expensive computation

Pure computation can also be too expensive for a property if callers may reasonably assume repeated reads are cheap.

A small derived value such as circle.area is a good fit. Recomputing a large report over millions of records on every attribute read is harder to justify.

If a result is expensive but stable, consider an explicit method or a documented caching strategy. The right choice depends on invalidation rules, memory cost, concurrency requirements, and whether callers need control over when computation happens.

Do not confuse property with cached_property

functools.cached_property serves a different purpose from property.

A normal property getter runs on each access:

@property
def total(self):
    return sum(self.items)

A cached property computes its value and then stores the result on the instance for later reads. That can be useful for expensive values that are effectively stable after computation, but caching introduces invalidation and memory questions.

Use a normal property when the value should track current object state. Consider caching only when recomputation cost is meaningful and the class has a clear answer for what happens if dependencies of the cached value change.

Properties are descriptors

The property built-in implements Python’s descriptor protocol. At a high level, the property object lives on the class and participates when an attribute with that name is accessed on an instance.

You normally do not need to implement descriptor methods yourself to use properties. The decorator syntax:

class Example:
    @property
    def value(self):
        return 42

is a convenient way to create a property object from the getter function.

Understanding this class-level placement explains an important rule: create properties on the class, not by assigning a property object to an individual instance and expecting normal managed-attribute behavior.

Custom descriptors become useful when the same attribute-management behavior must be reusable across many fields or classes. For one or a few managed attributes, property is usually easier to read.

Document the public property, not the backing field

The property name is part of the public interface. The backing attribute is an implementation detail.

A getter can carry a docstring:

class Product:
    def __init__(self, price):
        self._price = price

    @property
    def price(self):
        """Return the product price in the configured currency."""
        return self._price

Tools that inspect the property can use that documentation.

Avoid teaching callers to depend on _price unless it is intentionally part of the supported API. If external code bypasses the property and mutates backing state directly, validation and future refactoring become harder to preserve.

Keep inheritance behavior unsurprising

Properties can be overridden in subclasses, but partial overrides require care because getter, setter, and deleter functions belong to the property object.

If a subclass needs substantially different state rules, overriding the whole public behavior may be clearer than constructing a delicate chain of accessor overrides.

More importantly, callers should not have to know the concrete subclass merely to understand whether assigning a public attribute is valid. If one subtype makes a normally writable property read-only, or changes validation in an incompatible way, consider whether the subtype still honors the expectations of the base-class interface.

Inheritance concerns are design concerns rather than property-specific syntax problems. Properties simply make those contracts visible at attribute boundaries.

Test behavior at the public boundary

Tests should usually exercise the property through its public name:

temperature = Temperature(20)

assert temperature.celsius == 20.0

temperature.celsius = 25
assert temperature.celsius == 25.0

Validation tests should verify rejected inputs:

try:
    Temperature(-300)
except ValueError:
    pass
else:
    raise AssertionError("expected ValueError")

Avoid asserting _celsius merely to prove that the setter worked. The backing representation may change later while the public contract remains the same.

Tests can inspect internal state when that state is itself the subject of the test, but public-behavior tests provide more freedom to refactor implementation details.

Common pitfalls

Creating trivial properties for every field

A getter and setter that only return and assign a backing value add code without adding a useful invariant. Start with a plain attribute when plain access is sufficient.

Recursing inside a setter

Assigning to the property name from its own setter invokes the setter again. Store the underlying state under a different name.

Hiding I/O behind attribute access

Network, database, filesystem, and process operations are usually clearer as methods because they represent work and failure modes beyond ordinary state access.

Treating a missing setter as full immutability

A read-only property prevents direct assignment to that property. Other object state may remain mutable.

Adding validation without considering compatibility

Property syntax can preserve obj.name, but stricter validation changes which assignments succeed. Treat that as an API behavior change when callers may depend on the old behavior.

Caching without an invalidation rule

If a computed value depends on mutable state, caching it can make later reads stale. Decide how cached values are refreshed before choosing a caching mechanism.

Let attribute syntax match the abstraction

Python properties are most effective when they preserve a simple object interface while adding behavior that genuinely belongs to attribute access.

Start with public attributes when no management is needed. Introduce a property when a value needs validation, normalization, derivation, or controlled assignment. Keep backing state separate, test through the public name, and remember that read-only is narrower than immutable.

When an operation performs substantial work, has important side effects, or needs explicit caller control, use a method instead. The goal is not to maximize the number of properties in a class. It is to make obj.attribute mean what readers naturally expect it to mean.