Python Property Decorators Explained — @property, Getters, Setters and Real-World Patterns
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.
# 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}")
After bad assignment: $-99999
After string assignment: oops
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().
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}")
8000.0
Sarah Reese
Caught expected error: property 'full_name' of 'Employee' object has no setter
Adding a Setter — Validation That Lives Where It Belongs
Once you have a @property defined, you can add a setter using the @
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.
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}")
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
The Deleter, Caching, and Real-World Patterns Worth Knowing
The third component of the property trio is the deleter, decorated with @
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.
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
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
| Aspect | Plain Attribute | @property with Setter |
|---|---|---|
| Validation on assignment | None — any value accepted silently | Runs your custom logic on every assignment |
| Public API surface | Direct attribute: obj.age | Identical: obj.age — no API change |
| Read-only enforcement | Not possible — always writable | Omit the setter — raises AttributeError on write |
| Computed / derived values | Must be kept in sync manually | Recomputed automatically on every read |
| Refactoring risk | High — changing to method breaks callers | Zero — internal change, same public interface |
| Performance | Fastest — direct dict lookup | Tiny overhead — function call per access |
| Where validation lives | Scattered across every caller | Centralised inside the class — single source |
| Lazy caching support | Requires manual boilerplate | Built-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.
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.