Home Python Python Property Decorators Explained — @property, Getters, Setters and Real-World Patterns

Python Property Decorators Explained — @property, Getters, Setters and Real-World Patterns

In Plain English 🔥
Imagine a hotel minibar. Guests can see what's inside and take items, but the hotel controls what goes in, tracks what's removed, and won't let you stuff a live cat in there. Python's @property decorator is that hotel staff — it lets outsiders interact with your object's data, but you get to run validation, logging, or transformation behind the scenes, invisibly. From the outside it looks like a plain attribute. From the inside, you're in full control.
⚡ Quick Answer
Imagine a hotel minibar. Guests can see what's inside and take items, but the hotel controls what goes in, tracks what's removed, and won't let you stuff a live cat in there. Python's @property decorator is that hotel staff — it lets outsiders interact with your object's data, but you get to run validation, logging, or transformation behind the scenes, invisibly. From the outside it looks like a plain attribute. From the inside, you're in full control.

Every Python developer eventually writes a class where direct attribute access becomes a liability. Maybe a user sets an age to -5, a temperature to absolute zero in Fahrenheit without a conversion, or a username to an empty string. Without any guardrails, your object silently holds garbage data and the bug surfaces three function calls later — the worst kind of debugging experience. This is the exact problem property decorators were built to solve.

The naive fix is to rename your attribute to _age and write get_age() and set_age() methods — the Java approach. It works, but it's ugly. You force callers to change from obj.age to obj.get_age(), breaking existing code and making your API feel like a 2005 enterprise framework. Python's @property decorator gives you the control of getter/setter methods with the clean syntax of direct attribute access. You get validation, computed values, and read-only attributes — all without changing the public interface.

By the end of this article you'll understand exactly why @property exists (not just how to type it), how to add a setter and deleter correctly without common pitfalls, how to use properties for computed attributes, and how to confidently answer the property decorator questions that show up in Python interviews.

Why Direct Attribute Access Gets You Into Trouble

When you write self.temperature = value in an __init__ method, Python stores that value in the instance dictionary with zero checks. That's intentional — Python trusts you. But trust breaks down the moment your class becomes part of a larger system used by other developers (or future-you at 11pm).

Consider a BankAccount class. If balance is a plain attribute, nothing stops account.balance = -10000. Your business logic assumes balance is never negative, but the class doesn't enforce it. Every method that uses balance now has to defensively check it, scattering validation logic across your entire codebase.

The traditional OOP answer is encapsulation: hide the raw attribute and expose controlled access methods. Python agrees with the principle but disagrees with the ceremony. You shouldn't have to litter your API with get_balance() and set_balance() calls. The @property decorator lets you start with a simple attribute, then silently upgrade it to controlled access later — without touching a single line of calling code. That backward-compatibility is the real power.

bank_account_naive.py · PYTHON
123456789101112131415161718
# The problem: no validation on a plain attribute
class BankAccount:
    def __init__(self, owner: str, initial_balance: float):
        self.owner = owner
        self.balance = initial_balance  # Anyone can set this to anything

# --- Calling code ---
account = BankAccount("Alice", 500.00)
print(f"Initial balance: ${account.balance}")

# Nothing stops this nonsense:
account.balance = -99999
print(f"After bad assignment: ${account.balance}")

# Or this:
account.balance = "oops"
print(f"After string assignment: ${account.balance}")
▶ Output
Initial balance: $500.0
After bad assignment: $-99999
After string assignment: oops
⚠️
Watch Out:Plain attributes with no protection are fine for simple data containers (like dataclasses). Use @property when your attribute has business rules, needs validation, or its value is derived from other attributes. Don't over-engineer everything with properties just because you can.

Building Your First @property — Read-Only Computed Attributes

The simplest and most underused form of @property is the read-only computed attribute. This is a value that's always derived from other data — it has no business being stored separately because it would immediately risk going stale.

A perfect example: a person's full_name derived from first_name and last_name. If you stored full_name as a separate attribute, you'd have to remember to update it every time either name changes. That's a synchronisation bug waiting to happen. With @property, full_name is computed fresh every time it's accessed, guaranteed to always reflect the current state.

The @property decorator works by replacing the method with a special descriptor object. When you access instance.full_name, Python doesn't see it as a method call — it calls the underlying getter function automatically. To the caller it looks and feels exactly like a regular attribute, but it's actually a function running under the hood. This is why you access it as person.full_name, not person.full_name().

employee_computed_property.py · PYTHON
123456789101112131415161718192021222324252627282930313233343536
class Employee:
    def __init__(self, first_name: str, last_name: str, annual_salary: float):
        self.first_name = first_name
        self.last_name = last_name
        self.annual_salary = annual_salary

    @property
    def full_name(self) -> str:
        # Computed fresh each time — never out of sync
        return f"{self.first_name} {self.last_name}"

    @property
    def monthly_salary(self) -> float:
        # Derived from annual_salary — only one source of truth
        return round(self.annual_salary / 12, 2)

    def __repr__(self) -> str:
        return f"Employee({self.full_name}, ${self.annual_salary:,.2f}/yr)"


# --- Calling code ---
emp = Employee("Sarah", "Connor", 96000.00)

print(emp.full_name)        # Accessed like an attribute, NOT full_name()
print(emp.monthly_salary)   # Always accurate — no stale cached value

# Update the source data
emp.last_name = "Reese"
print(emp.full_name)        # Automatically reflects the change

# Attempting to SET a read-only property raises an error:
try:
    emp.full_name = "John Connor"  # No setter defined — this should fail
except AttributeError as error:
    print(f"Caught expected error: {error}")
▶ Output
Sarah Connor
8000.0
Sarah Reese
Caught expected error: property 'full_name' of 'Employee' object has no setter
⚠️
Pro Tip:Use read-only @property for any value that can be perfectly calculated from existing attributes. It eliminates entire categories of sync bugs. If you catch yourself writing code like self.full_name = self.first_name + ' ' + self.last_name in multiple places, that's your cue to make it a property instead.

Adding a Setter — Validation That Lives Where It Belongs

Once you have a @property defined, you can add a setter using the @.setter decorator. This is where validation logic lives — and it's a big deal that it lives here, inside the class, rather than scattered across every piece of calling code.

The setter must have the exact same name as the property. This trips up a lot of people at first. You're not writing two separate methods — you're decorating two methods of the same name, and Python merges them into one descriptor object with both a getter and a setter.

Notice the pattern in the code below: the setter assigns to self._temperature (with an underscore), not self.temperature. That underscore prefix is the conventional signal in Python that an attribute is internal — not truly private, but 'please don't touch this directly'. The property acts as the public interface. If you accidentally wrote self.temperature = value inside the setter, you'd cause infinite recursion because that assignment would trigger the setter again.

thermostat_with_validation.py · PYTHON
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
class Thermostat:
    # Celsius limits for a home thermostat
    MIN_TEMP_CELSIUS = 5
    MAX_TEMP_CELSIUS = 35

    def __init__(self, initial_temp_celsius: float):
        # Call the setter here so validation runs even on construction
        self.temperature = initial_temp_celsius

    @property
    def temperature(self) -> float:
        # Getter: simply return the internal value
        return self._temperature

    @temperature.setter
    def temperature(self, value: float) -> None:
        # Setter: validate BEFORE storing — reject bad data at the gate
        if not isinstance(value, (int, float)):
            raise TypeError(f"Temperature must be a number, got {type(value).__name__}")
        if value < self.MIN_TEMP_CELSIUS or value > self.MAX_TEMP_CELSIUS:
            raise ValueError(
                f"Temperature {value}°C is out of range "
                f"({self.MIN_TEMP_CELSIUS}–{self.MAX_TEMP_CELSIUS}°C)"
            )
        # Store in the private backing attribute — NOT self.temperature!
        self._temperature = float(value)

    @property
    def temperature_fahrenheit(self) -> float:
        # A read-only computed property derived from the validated Celsius value
        return round(self._temperature * 9 / 5 + 32, 2)

    def __repr__(self) -> str:
        return f"Thermostat({self._temperature}°C / {self.temperature_fahrenheit}°F)"


# --- Calling code ---
thermostat = Thermostat(20)  # Setter runs during __init__ too
print(thermostat)

thermostat.temperature = 22  # Clean setter call
print(thermostat)

# The interface hasn't changed — still looks like plain attribute access
print(f"Current temp: {thermostat.temperature}°C")

# Validation in action:
try:
    thermostat.temperature = 100  # Too hot — invalid
except ValueError as error:
    print(f"ValueError: {error}")

try:
    thermostat.temperature = "warm"  # Wrong type
except TypeError as error:
    print(f"TypeError: {error}")
▶ Output
Thermostat(20.0°C / 68.0°F)
Thermostat(22.0°C / 71.6°F)
Current temp: 22.0°C
ValueError: Temperature 100°C is out of range (5–35°C)
TypeError: Temperature must be a number, got str
⚠️
Critical Gotcha:Always call self.temperature = value (the property) inside __init__, not self._temperature = value (the backing attribute). If you bypass the property in __init__, your validation won't run during object construction — meaning an invalid object can be created and the bug won't surface until much later.

The Deleter, Caching, and Real-World Patterns Worth Knowing

The third component of the property trio is the deleter, decorated with @.deleter. It fires when someone calls del instance.attribute. In practice, deleters are rare — but they're perfect for cleanup tasks like releasing resources, clearing a cache, or resetting a connection.

A more immediately useful real-world pattern is the cached property. Some computations are expensive — parsing a large file, making a network call, or running a complex algorithm. You don't want to redo that work on every attribute access, but you also don't want to compute it eagerly in __init__ if it might never be needed. The solution: compute it lazily on first access and cache the result in the instance dictionary. Python 3.8+ ships functools.cached_property for exactly this. But understanding how to build it manually with @property and a backing attribute is a rite of passage that reveals how descriptors work under the hood.

The pattern below uses a _word_count backing attribute initialised to None as a sentinel. On first access the property does the work and stores the result. Every subsequent access skips the computation entirely — O(1) after the first call.

document_with_cache_and_deleter.py · PYTHON
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960
from functools import cached_property


class Document:
    def __init__(self, title: str, content: str):
        self.title = title
        self.content = content
        self._word_count_cache = None  # Sentinel: means 'not yet computed'

    # --- Manual lazy-caching pattern (educational — see cached_property below) ---
    @property
    def word_count(self) -> int:
        if self._word_count_cache is None:
            print("  [Computing word count for the first time...]")
            # Simulate an expensive operation by doing real work
            self._word_count_cache = len(
                [word for word in self.content.split() if word.strip()]
            )
        return self._word_count_cache

    @word_count.deleter
    def word_count(self) -> None:
        # Called when content changes — invalidate the stale cache
        print("  [Cache cleared — word count will recompute next access]")
        self._word_count_cache = None

    def update_content(self, new_content: str) -> None:
        self.content = new_content
        del self.word_count  # Explicitly invalidate cache via the deleter


# --- Python 3.8+ built-in solution for simple cases ---
class ReportCard:
    def __init__(self, student_name: str, scores: list):
        self.student_name = student_name
        self.scores = scores

    @cached_property
    def grade_average(self) -> float:
        # cached_property computes once and stores in instance.__dict__
        # After first access it bypasses the descriptor entirely — very fast
        print("  [Calculating grade average...]")
        return round(sum(self.scores) / len(self.scores), 2)


# --- Calling code ---
doc = Document("Python Tips", "Python is great and properties are useful")

print(f"Word count: {doc.word_count}")  # Triggers computation
print(f"Word count: {doc.word_count}")  # Uses cache — no recompute

doc.update_content("Short doc")          # Invalidates cache via deleter
print(f"Word count: {doc.word_count}")  # Recomputes with new content

print()

report = ReportCard("Maya", [88, 92, 79, 95, 83])
print(f"Average: {report.grade_average}")  # Computed once
print(f"Average: {report.grade_average}")  # Returned from instance dict
▶ Output
[Computing word count for the first time...]
Word count: 8
Word count: 8
[Cache cleared — word count will recompute next access]
[Computing word count for the first time...]
Word count: 2

[Calculating grade average...]
Average: 87.4
Average: 87.4
🔥
Interview Gold:If asked about performance optimisation in Python classes, mention functools.cached_property. It's a one-decorator solution for expensive computed attributes. But note its limitation: cached_property is NOT thread-safe by default. In a threaded context, two threads can both see None and both trigger the computation before either stores the result. In that case, use a threading.Lock or a manual property with a Lock.
AspectPlain Attribute@property with Setter
Validation on assignmentNone — any value accepted silentlyRuns your custom logic on every assignment
Public API surfaceDirect attribute: obj.ageIdentical: obj.age — no API change
Read-only enforcementNot possible — always writableOmit the setter — raises AttributeError on write
Computed / derived valuesMust be kept in sync manuallyRecomputed automatically on every read
Refactoring riskHigh — changing to method breaks callersZero — internal change, same public interface
PerformanceFastest — direct dict lookupTiny overhead — function call per access
Where validation livesScattered across every callerCentralised inside the class — single source
Lazy caching supportRequires manual boilerplateBuilt-in pattern; use cached_property in 3.8+

🎯 Key Takeaways

  • @property lets you add validation, transformation, and access control to attributes without changing the public API — existing code using obj.name keeps working unchanged.
  • Always store raw values in a backing attribute prefixed with underscore (self._value) inside setters to avoid infinite recursion — the property name and the backing attribute name must be different.
  • Call the property (self.temperature = value) inside __init__, never the backing attribute directly — otherwise your validation is silently skipped during object construction.
  • Use read-only @property for computed values that derive from other attributes — it eliminates sync bugs and guarantees a single source of truth. Use functools.cached_property in Python 3.8+ when that computation is expensive.

⚠ Common Mistakes to Avoid

  • Mistake 1: Naming the backing attribute the same as the property — Writing self.temperature = value inside the setter (instead of self._temperature = value) causes infinite recursion because the assignment triggers the setter again, over and over, until Python raises RecursionError: maximum recursion depth exceeded. Fix: always store the raw value in a private backing attribute with an underscore prefix, like self._temperature.
  • Mistake 2: Defining getter and setter as completely separate methods instead of chaining decorators — A common pattern is writing @property def temperature and then @property def temperature with the setter logic, forgetting the @temperature.setter decorator entirely. The result is the second definition silently overwrites the first, leaving you with only the setter (or only the getter). Fix: the setter must be decorated with @.setter, and both methods must share the same name.
  • Mistake 3: Bypassing the property in __init__ by writing self._temperature = value directly — This skips your validation on object construction, meaning an object can be instantiated with invalid state. The bug hides until some other method reads the attribute and behaves unexpectedly. Fix: always assign through the property in __init__ (self.temperature = value), not to the backing attribute. The setter will run during construction just like any other assignment.

Interview Questions on This Topic

  • QWhat is the difference between a @property getter and a regular method in Python, and why would you choose one over the other?
  • QIf you have an existing class with a plain public attribute that thousands of lines of code already use, how do you add validation to it without breaking any of that calling code?
  • QWhat happens if you define a @property but forget to use self._name (with underscore) for the backing attribute in the setter — walk me through exactly what error occurs and why.

Frequently Asked Questions

What is the @property decorator used for in Python?

@property turns a method into an attribute-style accessor. It lets you access a method as if it were a plain attribute (obj.name instead of obj.name()), and optionally pair it with a setter to run validation logic whenever the attribute is assigned. The main benefit is that you can add controlled access to class attributes without changing how outside code interacts with them.

Can I add a @property to an existing class without breaking code that already uses the attribute?

Yes — that's one of the key reasons @property exists. If your class has a plain attribute called price and you later replace it with a @property named price, all calling code that uses obj.price = value or reads obj.price continues to work identically. The change is completely internal to the class. This is a core advantage over getter/setter methods, which would require callers to change obj.price to obj.get_price().

What is the difference between @property and @cached_property in Python?

@property runs the decorated function every single time the attribute is accessed. @cached_property (available in functools since Python 3.8) runs the function only once on the first access, then stores the result directly in the instance's __dict__, making all future accesses a simple dictionary lookup with no function call overhead. Use @cached_property for expensive computations that won't change after the object is created, but be aware it is not thread-safe by default.

🔥
TheCodeForge Editorial Team Verified Author

Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.

← Previousfunctools Module in PythonNext →Beautiful Soup Web Scraping
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged