Python Property Decorator: Getters and Setters

Python encourages simplicity. Unlike Java, where you write explicit getters and setters from day one, Python lets you access class attributes directly. This works beautifully—until it doesn't.

Key Insights

  • Properties let you add validation and computed logic to attributes without breaking existing code that accesses them directly—start simple with public attributes and add properties only when needed.
  • Always store the actual value in a private attribute (with underscore prefix) to avoid infinite recursion when implementing setters.
  • Use properties for validation, computed values, and lazy loading, but keep simple attributes simple—not everything needs to be a property.

The Problem with Direct Attribute Access

Python encourages simplicity. Unlike Java, where you write explicit getters and setters from day one, Python lets you access class attributes directly. This works beautifully—until it doesn’t.

Consider a simple BankAccount class:

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

account = BankAccount(1000)
account.balance = -5000  # Oops! Negative balance allowed
print(account.balance)

This code has no validation. Anyone can set a negative balance, violate business rules, or assign invalid data types. In Java, you’d have written getBalance() and setBalance() methods from the start. But Python’s philosophy is different: start simple, add complexity only when needed.

Properties solve this elegantly. They let you add validation, logging, or computed logic without changing how other code accesses your attributes. Code that uses account.balance keeps working—no refactoring required.

Basic Property Decorator Syntax

The @property decorator transforms a method into a “getter” that’s accessed like an attribute. Here’s the syntax:

class BankAccount:
    def __init__(self, balance):
        self._balance = balance  # Store in private attribute
    
    @property
    def balance(self):
        """Get the current balance"""
        print("Getting balance...")
        return self._balance

account = BankAccount(1000)
print(account.balance)  # Calls the method, but looks like attribute access
# Output:
# Getting balance...
# 1000

Notice two critical details: we store the actual value in _balance (with an underscore), and we access it via balance (no underscore). This pattern prevents infinite recursion, which we’ll explore later.

The property decorator makes balance read-only. Try to set it, and Python raises an AttributeError:

account.balance = 2000  # AttributeError: can't set attribute

Adding Setters for Validation

To make a property writable, add a setter using the @property_name.setter decorator:

class Temperature:
    def __init__(self, celsius):
        self._celsius = None
        self.celsius = celsius  # Use the setter for validation
    
    @property
    def celsius(self):
        return self._celsius
    
    @celsius.setter
    def celsius(self, value):
        if value < -273.15:
            raise ValueError("Temperature below absolute zero!")
        self._celsius = value
    
    @property
    def fahrenheit(self):
        return self._celsius * 9/5 + 32

temp = Temperature(25)
print(f"{temp.celsius}°C = {temp.fahrenheit}°F")
# Output: 25°C = 77.0°F

temp.celsius = -300  # ValueError: Temperature below absolute zero!

The setter runs every time you assign to celsius, enforcing validation rules. Notice how __init__ uses self.celsius = celsius instead of directly setting self._celsius—this ensures validation runs even during object creation.

Computed Properties and Read-Only Attributes

Properties excel at computing values on-the-fly from other attributes. You don’t store the computed value; you calculate it when requested:

class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height
    
    @property
    def area(self):
        """Computed property - not stored"""
        return self.width * self.height
    
    @property
    def perimeter(self):
        return 2 * (self.width + self.height)

rect = Rectangle(5, 3)
print(rect.area)       # 15
print(rect.perimeter)  # 16

rect.width = 10
print(rect.area)       # 30 - automatically updated!

The area property always reflects current dimensions. No need to manually update it when width or height changes. This is cleaner and less error-prone than storing area as a separate attribute.

Use computed properties for derived data, format conversions, or lazy calculations. Keep them fast—they run every time you access the property.

Deleters and the Complete Property Pattern

The @property_name.deleter decorator handles attribute deletion. This is useful for cleanup operations or resource management:

class DatabaseConnection:
    def __init__(self, host):
        self._host = host
        self._connection = None
    
    @property
    def connection(self):
        if self._connection is None:
            print(f"Connecting to {self._host}...")
            self._connection = f"Connection to {self._host}"
        return self._connection
    
    @connection.setter
    def connection(self, value):
        print("Setting connection...")
        self._connection = value
    
    @connection.deleter
    def connection(self):
        print("Closing connection...")
        if self._connection:
            # Perform cleanup here
            self._connection = None

db = DatabaseConnection("localhost")
print(db.connection)  # Lazy initialization
# Output: Connecting to localhost...
#         Connection to localhost

del db.connection     # Triggers cleanup
# Output: Closing connection...

The deleter runs when you use del obj.property. It’s less common than getters and setters, but valuable for managing resources, cache invalidation, or triggering cleanup logic.

Property vs property() Function

The decorator syntax is syntactic sugar for the property() function. Here’s the equivalent code:

# Decorator syntax (preferred)
class Circle:
    def __init__(self, radius):
        self._radius = radius
    
    @property
    def radius(self):
        return self._radius
    
    @radius.setter
    def radius(self, value):
        if value < 0:
            raise ValueError("Radius cannot be negative")
        self._radius = value

# Function syntax (equivalent)
class Circle:
    def __init__(self, radius):
        self._radius = radius
    
    def get_radius(self):
        return self._radius
    
    def set_radius(self, value):
        if value < 0:
            raise ValueError("Radius cannot be negative")
        self._radius = value
    
    radius = property(get_radius, set_radius)

The function syntax takes up to four arguments: property(fget, fset, fdel, doc). Use it when you need to dynamically create properties or when working with older Python code. For new code, decorators are cleaner and more readable.

Best Practices and Common Pitfalls

Avoid Infinite Recursion: The most common mistake is forgetting to use a private attribute for storage:

# WRONG - Infinite recursion!
class Product:
    @property
    def price(self):
        return self.price  # Calls the property again!
    
    @price.setter
    def price(self, value):
        self.price = value  # Calls the setter again!

# RIGHT - Use a private attribute
class Product:
    @property
    def price(self):
        return self._price  # Access private attribute
    
    @price.setter
    def price(self, value):
        self._price = value  # Set private attribute

Start Simple: Don’t use properties prematurely. If an attribute needs no validation or computation, keep it simple:

# Overkill for a simple attribute
class User:
    @property
    def name(self):
        return self._name
    
    @name.setter
    def name(self, value):
        self._name = value

# Better - just use a regular attribute
class User:
    def __init__(self, name):
        self.name = name

Add properties when you need them. Python’s flexibility lets you convert attributes to properties later without breaking existing code.

Performance Considerations: Properties add a small overhead since they execute method calls. For performance-critical code accessing attributes millions of times, this matters. For typical applications, it’s negligible.

Documentation: Properties should have docstrings on the getter. This appears in help text:

class Account:
    @property
    def balance(self):
        """Current account balance in USD"""
        return self._balance

When to Use Properties:

  • Adding validation logic to attribute assignment
  • Computing values from other attributes
  • Implementing lazy loading or caching
  • Maintaining backward compatibility when refactoring
  • Logging or debugging attribute access

When NOT to Use Properties:

  • Simple data storage with no logic
  • Operations that are expensive or have side effects (users expect attribute access to be cheap)
  • When you need arguments (use regular methods instead)

Properties are Python’s answer to the getter/setter pattern, but with better syntax and backward compatibility. Use them judiciously to keep your code clean, validated, and maintainable while preserving Python’s simple attribute access style.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.