Java developers write getters and setters for every field, just in case one ever needs validation. Python skips that ceremony entirely. A Python property lets you ship a plain attribute today and slip validation or a computed value behind it tomorrow, without touching a single caller. This post covers @property getters, setters, and deleters, plus the patterns you will actually reuse.
“A good API is not just easy to use but also hard to misuse.”
Joshua Bloch, Effective Java
Last Updated: July 2026 | Tested on: Python 3.14.6 | Difficulty: Intermediate | Reading Time: 14 minutes
Here is the problem. You ship a class with a plain attribute, user.email. People read it and write to it all over the codebase. Six months later your boss says emails must be validated before they are saved. Now what? In Java you would rewrite every user.email into user.getEmail() and user.setEmail(), and touch hundreds of call sites. In Python you add @property and change nothing about how the attribute is used. The caller still writes user.email and user.email = "...", but now your validation runs quietly behind the scenes.
Think of @property like the auto-correct on your phone keyboard. You type a word the same way you always have. You do not change your habit at all. But something steps in between your key press and the screen, checks the word, and fixes it before anyone sees it. A property is that quiet helper sitting between user.email and the value it stores. The syntax stays simple, you keep full control.
The @property decorator turns a method into something that looks like a plain attribute. This post covers getters, setters, deleters, and the patterns that make properties the Pythonic alternative to Java-style accessor methods. Properties are also the finishing touch on encapsulation in Python: underscore-prefixed attributes keep the data private by convention, and a property decides exactly how outsiders read or change it.
Table of Contents
The Basic @property Pattern
The diagram lines up Python’s @property pattern next to Java-style getters and setters. On the Python side you still use clean dot syntax (obj.name), and validation logic runs behind the scenes. That is the core promise of a Python property: the caller never knows a method is running. The getter, setter, and deleter are a trio that step in when you read an attribute, assign to it, or delete it. The big win: you can start with a plain public attribute and bolt on validation later without touching a single line of the calling code.
A computed property works like a car’s speedometer: the car does not store your speed anywhere, it works it out live from the wheels every time you glance at the dial. The area below behaves the same way.
📄 basic_property.py: read-only computed attribute
class Circle:
def __init__(self, radius):
self.radius = radius
@property
def area(self):
from math import pi
return pi * self.radius ** 2
@property
def circumference(self):
from math import pi
return 2 * pi * self.radius
c = Circle(5)
print(f"Radius: {c.radius}")
print(f"Area: {c.area:.2f}") # No parentheses!
print(f"Circumference: {c.circumference:.2f}")
c.radius = 10 # Update radius
print(f"New area: {c.area:.2f}") # Automatically recalculated
# Can't set a read-only property
try:
c.area = 100
except AttributeError as e:
print(f"Error: {e}")
▶ Output
Radius: 5 Area: 78.54 Circumference: 31.42 New area: 314.16 Error: property 'area' of 'Circle' object has no setter
What happened here: @property turns a method into attribute-style access. c.area looks like a normal attribute, but the method runs every single time you read it. There are no parentheses, so you write c.area, not c.area(). A property with only a getter is read-only by default, so trying to assign to it raises AttributeError. And because the area is computed fresh from the current radius, bumping radius from 5 to 10 makes area jump on its own. Nothing is stored stale.
Getter and Setter: Validated Attributes
A read-only Python property is handy, but the real power shows up when you add a setter. The setter is a second method that runs every time someone assigns to the attribute. It is the perfect spot to check the value and clean it up before storing it. Think of it like a bouncer at a club door: nobody gets in without being checked first. In the example below, a user named Rahul signs up with a messy mixed-case email, and later the account email is switched to an address belonging to a colleague named Aditi. Watch the setter quietly clean up both.
📄 getter_setter.py: validation on assignment
class User:
def __init__(self, name, email, age):
self.name = name
self.email = email # Triggers the setter!
self.age = age # Triggers the setter!
@property
def email(self):
return self._email
@email.setter
def email(self, value):
if not isinstance(value, str) or "@" not in value:
raise ValueError(f"Invalid email: {value}")
self._email = value.lower().strip()
@property
def age(self):
return self._age
@age.setter
def age(self, value):
if not isinstance(value, int) or value < 0 or value >= 150:
raise ValueError(f"Invalid age: {value}")
self._age = value
user = User("Rahul", "Rahul@TechnoScripts.com", 28)
print(f"{user.name}: {user.email} (age {user.age})")
# Setter validates and normalizes
user.email = " ADITI@Example.COM "
print(f"Updated email: {user.email}")
# Invalid data is rejected
try:
user.email = "not-an-email"
except ValueError as e:
print(f"Error: {e}")
try:
user.age = -5
except ValueError as e:
print(f"Error: {e}")
▶ Output
Rahul: rahul@technoscripts.com (age 28) Updated email: aditi@example.com Error: Invalid email: not-an-email Error: Invalid age: -5
What happened here: The @email.setter runs validation and cleanup (lowercase the text, strip the spaces) every single time you assign to user.email. That includes the very first assignment inside __init__, which is why "Rahul@TechnoScripts.com" comes back as rahul@technoscripts.com. The calling code looks exactly like plain attribute access, so nobody using your class has to learn anything new. One thing to spot: the real value lives in self._email with an underscore. If you stored it in self.email instead, the setter would call itself forever. We cover that trap in the Common Mistakes section below.
The Deleter: Cleanup on Delete
The third member of the Python property trio is the deleter. It runs when someone writes del obj.attribute. You will not need it every day, but it is great when deleting something should also trigger cleanup, like clearing a cache or closing a file handle. Here we use it to throw away a cached result so the next read recomputes from scratch.
📄 deleter.py: custom cleanup when an attribute is deleted
class CachedResult:
def __init__(self, compute_fn):
self._compute_fn = compute_fn
self._cache = None
@property
def value(self):
if self._cache is None:
print(" Computing (expensive!)...")
self._cache = self._compute_fn()
return self._cache
@value.deleter
def value(self):
print(" Cache cleared")
self._cache = None
def expensive_computation():
return sum(range(1_000_000))
result = CachedResult(expensive_computation)
print(f"First access: {result.value}") # Computes
print(f"Second access: {result.value}") # Cached
del result.value # Clear cache
print(f"Third access: {result.value}") # Recomputes
▶ Output
Computing (expensive!)... First access: 499999500000 Second access: 499999500000 Cache cleared Computing (expensive!)... Third access: 499999500000
What happened here: The first read prints “Computing” and does the heavy sum(range(1_000_000)) work, then stores the answer in self._cache. The second read sees the cache is already filled and returns it instantly, no “Computing” line. Then del result.value fires the deleter, which prints “Cache cleared” and resets the cache to None. So the third read has to compute all over again. It is like a kettle that keeps the water warm until you pour it out, then has to boil again next time.
The Evolution: From Attribute to Property
This is the whole point of properties in one before-and-after. Think of a shop that renovates everything inside but keeps the same entrance and signboard: customers walk in exactly the way they always did and never notice the upgrade happened. Version 1 ships with a plain price attribute. Version 2 adds validation behind the same name, and the code that uses the class does not change at all.
📄 evolution.py: adding validation without breaking callers
# Version 1: simple attribute (ship it!)
class ProductV1:
def __init__(self, name, price):
self.name = name
self.price = price # Just an attribute
# Version 2: added validation (no caller changes needed!)
class ProductV2:
def __init__(self, name, price):
self.name = name
self.price = price # Triggers setter
@property
def price(self):
return self._price
@price.setter
def price(self, value):
if value < 0:
raise ValueError(f"Price can't be negative: {value}")
self._price = round(value, 2)
# Callers use IDENTICAL syntax for both versions
p1 = ProductV1("Book", 499)
p2 = ProductV2("Book", 499.999)
print(f"V1: {p1.price}")
print(f"V2: {p2.price}") # Rounded!
try:
p2.price = -100
except ValueError as e:
print(f"V2 rejects: {e}")
▶ Output
V1: 499 V2: 500.0 V2 rejects: Price can't be negative: -100
What happened here: This is the feature that makes properties worth learning. ProductV1 uses a plain attribute. ProductV2 swaps in a @property with a validating setter that rounds the price and rejects negatives. Look at the two lines that create them: they are written the same way, Product("Book", price). The setter even reshapes the input, which is why 499.999 prints as 500.0. No get_price() or set_price() refactor anywhere. That is why seasoned Python folks say “start with public attributes”: you can always upgrade to @property later without breaking a single caller.
Practical Python Property Patterns
Once the basics click, the same idea shows up in a handful of shapes you will reach for again and again. Think of a restaurant bill: the grand total is never stored anywhere, it is always worked out from the items and taxes at the moment of printing. Properties do the same for your objects. Here are four patterns in one small Employee class built around an employee named Prathamesh Kulkarni: a computed name that joins two fields, an age that figures itself out from the calendar, a validated setter that cleans the salary, and a read-only value derived from another property.
📄 patterns.py: common real-world patterns
from datetime import date
class Employee:
def __init__(self, first_name, last_name, birth_year, salary):
self.first_name = first_name
self.last_name = last_name
self.birth_year = birth_year
self.salary = salary
# Pattern 1: Computed property (derived from other attributes)
@property
def full_name(self):
return f"{self.first_name} {self.last_name}"
# Pattern 2: Calculated from current date
@property
def age(self):
return date.today().year - self.birth_year
# Pattern 3: Validated setter with normalization
@property
def salary(self):
return self._salary
@salary.setter
def salary(self, value):
if value < 0:
raise ValueError("Salary can't be negative")
self._salary = round(value, 2)
# Pattern 4: Read-only derived property
@property
def monthly_salary(self):
return round(self._salary / 12, 2)
emp = Employee("Prathamesh", "Kulkarni", 1998, 1200000)
print(f"Name: {emp.full_name}")
print(f"Age: {emp.age}")
print(f"Annual: Rs.{emp.salary:,.2f}")
print(f"Monthly: Rs.{emp.monthly_salary:,.2f}")
▶ Output
Name: Prathamesh Kulkarni Age: 28 Annual: Rs.1,200,000.00 Monthly: Rs.100,000.00
What happened here: Four properties, four jobs. full_name stitches the first and last name together on the fly, so it always reflects the current values. age is computed from date.today().year, which is why Prathamesh (born 1998) shows as 28 when we ran this in 2026. Run it in a later year and the number climbs on its own, with no stored birthday to update. The salary setter rounds to two decimals and blocks negatives, and monthly_salary divides the stored salary by 12 as a read-only value. Notice you never call any of these with parentheses, they all look like plain attributes.
Common Mistakes
Mistake 1: Infinite recursion with same name
📄 mistake_recursion.py
# BAD: infinite recursion!
class BadClass:
@property
def name(self):
return self.name # Reading self.name calls this getter again!
@name.setter
def name(self, value):
self.name = value # Assigning self.name calls this setter again!
# GOOD: use an underscore name for the real storage
class GoodClass:
@property
def name(self):
return self._name # self._name is a plain attribute, no property
@name.setter
def name(self, value):
self._name = value # Stores it without re-triggering the property
What happened here: In BadClass, the getter returns self.name, but self.name is the property itself, so reading it calls the getter, which reads self.name, which calls the getter, on and on until Python gives up with a RecursionError. The fix in GoodClass is one character: store the value in self._name. The leading underscore makes it an ordinary attribute that the property does not intercept, so the loop never starts. The property is the public face; the underscore name is the private drawer where the value actually sits.
Mistake 2: Heavy computation in properties
A Python property should be quick. When someone writes obj.attribute, they expect an instant answer, the same as reading any normal attribute. If your property secretly runs a database query, hits an API (Application Programming Interface), or grinds through an expensive calculation, that surprise will bite someone later. Make it a method instead, like obj.calculate_report(), so the parentheses warn the caller that real work is about to happen and the result is not free.
Best Practices
- DO start with public attributes, and add
@propertyonly when you actually need validation or computation - DO use
self._attrfor internal storage so the property does not call itself - DO keep properties fast and free of side effects
- DON’T use properties for slow operations (file input/output, network calls, heavy computation)
- DON’T create Java-style
get_x()andset_x()methods - DON’T reuse the same name for the property and its internal storage
Conclusion
The @property decorator bridges the gap between a plain attribute and a full getter and setter pair. Start with simple attributes, reach for a Python property the day you need validation or a computed value, and your calling code never has to change. The getter reads, the setter validates, and the deleter handles cleanup when an attribute is removed. That trio is the whole toolkit.
Properties round out the Object-Oriented Programming (OOP) toolkit. In the modules tutorial, you will shift from writing classes to organizing code, learning how Python modules let you split a large program into manageable, reusable files. And if you want to jump to any other topic, from absolute basics to AI/ML, browse the full Python + AI/ML tutorial series home.
Practice Exercises
- Exercise 1: Build a
Temperatureclass that stores Celsius internally but exposes afahrenheitproperty. Reading it converts from Celsius, and writing to it converts back and updates the stored Celsius value. - Exercise 2: Write a
BankAccountclass with abalanceproperty. The setter should reject any value below zero by raisingValueError, and add a read-onlybalance_in_wordsproperty that returns “low”, “okay”, or “healthy”. - Exercise 3: Take a class you wrote earlier with a plain public attribute and upgrade just that one attribute to a validated
@property, without changing any code that already uses the class. Confirm the old usage still runs unchanged.
Frequently Asked Questions
What is @property in Python?
@property turns a method into an attribute-style access. obj.name calls the getter method behind the scenes. You can add @name.setter for assignment and @name.deleter for deletion. It’s how Python does getters and setters without ugly method calls.
Why use @property instead of get/set methods?
Python’s @property lets you start with simple attributes and add validation later without changing the calling code. obj.name = 'X' works whether name is a plain attribute or a property. Java-style obj.set_name('X') requires changing every caller when you add validation.
How do I make a read-only property in Python?
Define only the getter with @property and do not add a @attr.setter. Assigning to that Python property then raises AttributeError. This is perfect for computed values like area, age, or full_name that derive from other attributes.
Why does my property cause infinite recursion?
You’re using the same name for the property and internal storage. self.name = value inside the setter calls the setter again. Use self._name (underscore prefix) for internal storage: self._name = value.
When should I use @property vs a regular method?
Use @property for fast, attribute-like access: computed values, validated storage, read-only attributes. Use regular methods for operations with side effects, slow computations, or actions that callers should know aren’t free (database queries, API calls).
Interview Questions on Python @property
If you can walk through these without peeking, you are ready for this topic in an interview.
Q: What actually happens under the hood when you decorate a method with @property?
property is a built-in class that implements the descriptor protocol (__get__, __set__, __delete__). The decorator replaces your method with a property object stored on the class, and when you access obj.name, Python’s attribute lookup finds that descriptor on the class and routes the read through its getter instead of fetching a value from the instance dictionary. @name.setter and @name.deleter return new property objects with the extra functions attached.
Q: Your API response time spikes after a refactor, and profiling shows a property on the Order class is the hot spot. What do you check first?
Check what the property’s getter actually does on each read, because a property recomputes every single time it is accessed. A common culprit is a getter that runs a database query or a heavy loop, and then gets read repeatedly inside serialization or a list comprehension, multiplying the cost. The fix is to either convert it to an explicit method so the cost is visible at the call site, or cache the result, for example with functools.cached_property.
Q: How is functools.cached_property different from @property?
@property runs its getter on every access, while functools.cached_property runs it once, stores the result in the instance’s __dict__, and serves that stored value on later reads. That makes it ideal for expensive values that do not change during the object’s lifetime. It has no setter, and you invalidate the cache by deleting the attribute with del obj.attr, which forces a recompute on the next read.
Q: You made balance a read-only property, yet a bug report shows the balance was corrupted because some code wrote account._balance = -500 directly. Is the read-only property broken?
No. A getter-only property blocks account.balance = x, but the backing field _balance is still an ordinary attribute, and the single underscore is only a convention that says “internal, do not touch”. Python deliberately does not enforce privacy. You can make accidental access harder with name mangling (__balance becomes _ClassName__balance), but the real fix is a code review rule: outside the class, only the public property is used.
Q: A parent class defines a name property with only a getter. How do you add a setter in a subclass without rewriting the getter?
Decorate the subclass method with @ParentClass.name.setter. That builds a new property that reuses the parent’s getter and adds your setter, and assigning it to name in the subclass overrides the parent’s read-only version. The equivalent long form is name = property(ParentClass.name.fget, new_setter).
Q: Why does validation in a property setter also run for values passed to __init__?
Because __init__ typically contains a plain assignment like self.email = email, and any assignment to that attribute name on the instance is routed through the setter defined on the class. So the very first value gets validated exactly like every later update. The flip side is a common bug: if __init__ writes to the backing field directly with self._email = email, validation is silently skipped for the initial value.
Further reading: for the full reference, see the official Python documentation.
Related Posts
Previous: Python: Operator Overloading and Custom Object Behavior
Next: Python OOP Project: Build a Bank Account Manager (Capstone)
Series Home: Python + AI/ML Tutorial Series

No comment