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

isn't just a matter of syntax; it's about the promise you make to the caller. When you use a property via the @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.

Python Properties vs Methods: The Contract You Didn’t Know You Were Making
Python Properties vs Methods: The Contract You Didn’t Know You Were Making

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,

uses the descriptor protocol to make properties work. This protocol defines how objects behave when accessed or modified. A property is effectively a method disguised as an attribute. While this allows for clean syntax, it requires discipline. For example, a property is inherently read-only unless you explicitly define a setter using the @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,

allows you to define an 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

for performance while keeping the resulting object's interface clean and synchronous.

@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)
Python Properties vs Methods: The Contract You Didn’t Know You Were Making

Fancy watching it?

Watch the full video and context

3 min read