Python Properties vs Methods: The Contract You Didn’t Know You Were Making
The Psychological Contract of Object Access
Choosing between a property and a method in @property decorator, you communicate that the access is cheap, safe, and deterministic. It feels like an attribute, so the user assumes they can read it repeatedly without a performance penalty or a crash.

Methods tell a different story. They signal that work is happening. A method call implies the potential for complexity, side effects, or external communication with a database. If your code performs heavy computation or network I/O, hiding it behind a property breaks the mental model of the developer using your API.
Implementation and the Descriptor Protocol
Under the hood, @property_name.setter syntax.
class UserAccount:
def __init__(self, status: str):
self._status = status
@property
def is_active(self) -> bool:
return self._status == "active"
@is_active.setter
def is_active(self, value: bool):
self._status = "active" if value else "closed"
The Danger of Hidden I/O
You might feel tempted to trigger database saves inside a property setter. Resist this. Performing persistence or network calls inside a property violates the principle of least astonishment. If a database call fails or blocks, the caller won't expect an attribute assignment to be the culprit. Always keep I/O explicit. Use a property to update local state and a dedicated save() method to handle the actual persistence. This allows for batching updates and keeps your object's behavior predictable.
Async Properties: Just Because You Can Doesn't Mean You Should
Technically, async property. You can use await on an attribute access, but this is a massive design smell. Async operations imply scheduling and potential failure—the exact opposite of what a property represents.
Instead of async properties, use an asynchronous class method to load data into a simple data class. This pattern separates the "work" of fetching data from the "state" of the object itself. You get the benefits of
@dataclass
class UserAccount:
username: str
status: str
@classmethod
async def load(cls, user_id: int):
# Perform async I/O here
username, status = await asyncio.gather(
repo.fetch_name(user_id),
repo.fetch_status(user_id)
)
return cls(username, status)

Fancy watching it?
Watch the full video and context