The @property decorator is one of Python's most elegant tools for encapsulation and attribute access control. It lets you turn methods into attributes that can be accessed directly, without parentheses, while keeping all validation and computation logic under the hood. If you want to write cleaner, more professional Python code, mastering @property is a must.

In this complete guide, you'll learn everything from basic to advanced property techniques in Python, with tested code examples and industry best practices.

The Problem with Direct Attribute Access

In many object-oriented languages like Java and C++, encapsulation is done through private attributes with public getter and setter methods. Python takes a different approach: everything is public by default. This can lead to trouble when an attribute needs validation or additional logic down the road.

class BankAccount:
    def __init__(self, holder, balance):
        self.holder = holder
        self.balance = balance

Direct access -- no protection at all

account = BankAccount("John", 1000) account.balance = -500 # Negative balance without warning!

The issue is clear: anyone can set balance to a negative value, something a real bank account should never allow. We could solve this with traditional getter and setter methods, but the syntax would become more verbose and less intuitive.

Creating Your First Property with @property

The @property decorator turns a method into a property that can be accessed like a regular attribute:

class Temperature:
    def __init__(self, celsius):
        self._celsius = celsius
@property
def celsius(self):
    """Returns the temperature in Celsius."""
    return self._celsius

@property
def fahrenheit(self):
    """Converts Celsius to Fahrenheit automatically."""
    return (self._celsius * 9 / 5) + 32

Clean usage

temp = Temperature(25) print(temp.celsius) # 25 print(temp.fahrenheit) # 77.0

Notice how temp.celsius and temp.fahrenheit are accessed as attributes, without parentheses. Internally, though, they are methods that can contain any logic. That's the power of @property: attribute syntax with method behavior.

According to the official Python documentation, the property() function returns a property attribute and can take up to four arguments: fget, fset, fdel, and doc. The @property decorator is just syntactic sugar for using property(fget).

Adding Validation with @property.setter

The @property.setter lets you define a setter method for the property, running validation logic every time the value is changed:

class BankAccount:
    def __init__(self, holder, balance=0):
        self.holder = holder
        self._balance = balance
@property
def balance(self):
    return self._balance

@balance.setter
def balance(self, value):
    if value < 0:
        raise ValueError("Balance cannot be negative!")
    self._balance = value

def deposit(self, amount):
    if amount <= 0:
        raise ValueError("Amount must be positive!")
    self.balance += amount  # Uses the setter automatically

def withdraw(self, amount):
    if amount <= 0:
        raise ValueError("Amount must be positive!")
    if amount > self.balance:
        raise ValueError("Insufficient balance!")
    self.balance -= amount  # Uses the setter automatically

Safe usage

account = BankAccount("Mary", 1000) account.deposit(500) print(account.balance) # 1500 account.withdraw(200) print(account.balance) # 1300

account.balance = -100 # ValueError!

The setter is called automatically whenever you assign a value to the property. This includes assignments inside other class methods like deposit and withdraw. All validation logic lives in one place, avoiding code duplication.

The Real Python tutorial on @property shows how this approach simplifies code maintenance and prevents bugs in production.

Using @property.deleter

The @property.deleter decorator defines what happens when you use del on a property. It's handy for releasing resources or resetting values:

class Session:
    def __init__(self, user):
        self._user = user
        self._tokens = ["token123"]
@property
def user(self):
    return self._user

@property
def tokens(self):
    return self._tokens

@tokens.deleter
def tokens(self):
    print("Clearing session tokens...")
    self._tokens = []

session = Session("admin") print(session.tokens) # ['token123'] del session.tokens print(session.tokens) # []

The deleter lets you run cleanup code when an attribute is deleted. This is especially useful for managing resources such as database connections, open files, or caches.

Computed Properties (Derived Attributes)

Computed properties are attributes whose value is dynamically calculated from other attributes. They don't need a setter if they are read-only:

class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height
@property
def area(self):
    return self.width * self.height

@property
def perimeter(self):
    return 2 * (self.width + self.height)

@property
def diagonal(self):
    return (self.width ** 2 + self.height ** 2) ** 0.5

r = Rectangle(10, 5) print(r.area) # 50 print(r.perimeter) # 30 print(f"{r.diagonal:.2f}") # 11.18

If width changes, properties reflect the change

r.width = 20 print(r.area) # 100 (dynamically calculated)

Computed properties are a great alternative to methods like get_area() or get_perimeter(). The attribute syntax makes the code more natural and readable. The Python Descriptor HowTo explains in detail how the property mechanism works internally.

This concept is widely used in OOP. If you are studying Object-Oriented Programming in Python, understanding properties is essential for applying encapsulation correctly.

Property vs Traditional Getters and Setters

In languages like Java, you often see:

// Java
public class Person {
    private String name;
public String getName() {
    return name;
}

public void setName(String name) {
    this.name = name;
}

}

person.getName(); person.setName("John");

In Python, the same pattern with @property is much cleaner:

class Person:
    def __init__(self, name):
        self._name = name
@property
def name(self):
    return self._name

@name.setter
def name(self, value):
    if not value.strip():
        raise ValueError("Name cannot be empty!")
    self._name = value.strip()

@name.deleter
def name(self):
    print("Deleting name...")
    self._name = None

p = Person(" Mary ") print(p.name) # Mary (direct access) p.name = "Anna" print(p.name) # Anna

The key difference is that client code never needs to know whether it's accessing an attribute or a method. You can start with a simple attribute, and later if validation becomes necessary, you can turn it into a property without breaking the public API. This principle is recommended by PEP 8 -- Style Guide for Python Code, which advocates using properties for consistency.

Encapsulation and Validation with Smart Setters

One of the most common use cases for @property is data validation in the setter. Let's create a User class with robust validation:

import re

class User: def init(self, email, age): self.email = email # Uses the setter during init self.age = age # Uses the setter during init self._active = True

@property
def email(self):
    return self._email

@email.setter
def email(self, value):
    if not re.match(r'^[\w\.-]+@[\w\.-]+\.\w+$', value):
        raise ValueError("Invalid email!")
    self._email = value.lower()

@property
def age(self):
    return self._age

@age.setter
def age(self, value):
    if not isinstance(value, int):
        raise TypeError("Age must be an integer!")
    if value < 0 or value > 150:
        raise ValueError("Age must be between 0 and 150!")
    self._age = value

@property
def active(self):
    return self._active

Usage with automatic validation

user = User("[email protected]", 25) print(user.email) # [email protected] (lowercased) print(user.age) # 25

user.email = "invalid" # ValueError!

user.age = 200 # ValueError!

Notice how the constructor also goes through the setters, ensuring even newly created objects have valid data. The Stack Overflow community provides a deep explanation of how the property decorator works and its nuances.

Cached Properties (Lazy Evaluation)

When a computed property is expensive to calculate, you can cache the result and recalculate only when needed:

class Report:
    def __init__(self, data):
        self.data = data
        self._cache = {}
@property
def full_analysis(self):
    if "analysis" not in self._cache:
        print("Computing full analysis... (expensive operation)")
        result = {
            "total": sum(self.data),
            "average": sum(self.data) / len(self.data),
            "maximum": max(self.data),
            "minimum": min(self.data),
            "size": len(self.data)
        }
        self._cache["analysis"] = result
    return self._cache["analysis"]

def invalidate_cache(self):
    self._cache = {}

report = Report([10, 20, 30, 40, 50]) print(report.full_analysis["average"]) # Computes on first access print(report.full_analysis["total"]) # Uses cache on second access report.invalidate_cache() print(report.full_analysis) # Recomputes

This pattern is called lazy evaluation and is especially useful for I/O operations, database queries, or intensive data processing, as demonstrated in The Hitchhiker's Guide to Python.

Inheritance and Properties

Properties behave like methods in inheritance: they can be overridden in subclasses to extend or modify behavior:

class Animal:
    def __init__(self, name):
        self._name = name
@property
def name(self):
    return self._name

@property
def sound(self):
    return "..."

class Dog(Animal): @property def sound(self): return "Woof!"

class Cat(Animal): @property def sound(self): return "Meow!"

class Parrot(Animal): def init(self, name): super().init(name) self._words = []

def learn(self, word):
    self._words.append(word)

@property
def sound(self):
    if self._words:
        return ", ".join(self._words)
    return "..."

animals = [Dog("Rex"), Cat("Mimi"), Parrot("Louro")] for animal in animals: if isinstance(animal, Parrot): animal.learn("Hello!") animal.learn("Polly wants a cracker") print(f"{animal.name}: {animal.sound}")

Property overriding works just like regular method overriding. You can redeclare @property in the subclass or use super() to extend the original implementation. The GeeksforGeeks guide on @property provides additional examples of inheritance with properties.

Properties as Read-Only Attributes

To create attributes that can be read but not modified after creation, just define the getter without the setter:

class Configuration:
    def __init__(self):
        self._version = "2.0.1"
        self._created_at = "2026-01-15"
@property
def version(self):
    return self._version

@property
def created_at(self):
    return self._created_at

config = Configuration() print(config.version) # 2.0.1 print(config.created_at) # 2026-01-15

config.version = "3.0.0" # AttributeError!

Read-only properties are ideal for exposing metadata, configuration constants, or system information without risk of accidental modification. For a deeper dive on encapsulation, check out our article on Python Decorators, which covers advanced use of decorators like @property.

Property with Type Hints

Modern Python supports type hints in properties, improving readability and enabling static type checking with tools like mypy:

class Product:
    def __init__(self, name: str, price: float) -> None:
        self._name = name
        self._price = price
@property
def name(self) -> str:
    return self._name

@name.setter
def name(self, value: str) -> None:
    if not value.strip():
        raise ValueError("Name cannot be empty!")
    self._name = value.strip()

@property
def price(self) -> float:
    return self._price

@price.setter
def price(self, value: float) -> None:
    if value < 0:
        raise ValueError("Price cannot be negative!")
    self._price = round(value, 2)

@property
def price_with_tax(self) -> float:
    """Returns the price with 10% tax."""
    return round(self._price * 1.1, 2)

p = Product("Laptop", 3500.00) print(f"{p.name}: ${p.price:.2f} (with tax: ${p.price_with_tax:.2f})")

Type hints in properties help IDEs provide autocomplete and catch errors at development time, while also serving as living documentation. Learn more about type hints in the official typing module documentation.

When to Avoid @property

Despite being powerful, overusing properties can make code confusing. Avoid @property when:

  • The method performs an expensive operation that should not appear deceptively fast (prefer an explicit method like .compute_report())
  • The method has significant side effects (properties should be safe and predictable)
  • The method accepts parameters (properties cannot take arguments other than self)
  • Accessing the property frequently raises exceptions (this breaks the expectation that attributes are safe)
# BAD: expensive operation disguised as an attribute
class Database:
    @property
    def all_users(self):
        # Heavy SQL query -- better as an explicit method
        return self._execute_query("SELECT * FROM users")

GOOD: explicit method for heavy operations

class Database: def list_users(self): return self._execute_query("SELECT * FROM users")

The golden rule: properties should be cheap, predictable, and side-effect-free. For expensive operations or methods that take parameters, stick with regular methods.

Conclusion

The @property decorator is an indispensable tool in the Python developer's toolkit. It lets you write clean, safe code that follows the encapsulation principle without sacrificing simplicity. You can start with simple public attributes and, as needs grow, add validation and logic without breaking your class's public API.

Mastering @property is an important step toward writing professional Python code. When combined with type hints, inheritance, and sound validation practices, you create robust, testable, and maintainable classes.

Keep learning: explore our complete guide to Python Magic Methods to further deepen your OOP knowledge in Python.