While the @property decorator allows you to define a getter, the full power of the property protocol is unlocked when you also define the corresponding setter and deleter methods. These methods are invoked when an attempt is made to assign a value to the property or delete it using the del statement, respectively. This mechanism transforms a simple attribute access into a controlled method call, enabling validation, computation, and side effects.

Defining a Setter with @<property_name>.setter

To create a setter for a property, you decorate a method with @<property_name>.setter. The name of this method must be the same as the original property. This method should accept two parameters: self and the value being assigned. Crucially, the setter method does not return a value; its purpose is to modify the internal state of the object.

class Temperature:
    def __init__(self, celsius=0):
        self._celsius = celsius  # Store the actual data in a "private" attribute

    @property
    def celsius(self):
        return self._celsius

    @celsius.setter
    def celsius(self, value):
        if value < -273.15:
            raise ValueError("Temperature below absolute zero is not possible.")
        print(f"Setting celsius to {value}")
        self._celsius = value

# Usage
temp = Temperature(25)
print(temp.celsius)  # Output: 25

temp.celsius = 30    # Output: Setting celsius to 30
print(temp.celsius)  # Output: 30

try:
    temp.celsius = -300 # This will raise a ValueError
except ValueError as e:
    print(e)  # Output: Temperature below absolute zero is not possible.

In this example, assigning a value to .celsius triggers the setter method. This allows us to intercept the assignment, validate the input (preventing physically impossible values), and even log the action before finally storing the value in the underlying _celsius attribute. Without this setter, the user could directly assign to temp._celsius = -300, bypassing any validation, which is why the internal attribute is conventionally prefixed with an underscore to signal it is not part of the public API.

Defining a Deleter with @<property_name>.deleter

Similarly, a deleter method is defined using the @<property_name>.deleter decorator. This method is called when the property is deleted via del obj.property. Its typical use cases include cleaning up resources, resetting a value to a default, or implementing logic where the property’s existence is conditional.

class Configuration:
    def __init__(self):
        self._api_key = None

    @property
    def api_key(self):
        if self._api_key is None:
            raise AttributeError("API key has not been set or has been deleted.")
        # In a real scenario, you might decrypt the key here
        return f"decrypted_{self._api_key}"

    @api_key.setter
    def api_key(self, value):
        # In a real scenario, you might encrypt the key here
        print("API key has been set.")
        self._api_key = value

    @api_key.deleter
    def api_key(self):
        print("API key has been deleted. Clearing from memory.")
        # Securely wipe the value; setting to None helps with garbage collection.
        self._api_key = None

# Usage
config = Configuration()
config.api_key = "secret123"  # Output: API key has been set.
print(config.api_key)          # Output: decrypted_secret123

del config.api_key            # Output: API key has been deleted. Clearing from memory.

try:
    print(config.api_key)     # This will now raise an AttributeError
except AttributeError as e:
    print(e)  # Output: API key has not been set or has been deleted.

The Importance of the Underlying Data Attribute

A common pitfall for beginners is to define a property with a getter and setter but forget to create a separate underlying attribute to store the data. This leads to an infinite recursion error.

Incorrect Implementation:

class BadProperty:
    @property
    def name(self):
        return self.name  # Recursion! This calls the getter again, forever.

    @name.setter
    def name(self, value):
        self.name = value  # Recursion! This calls the setter again, forever.

# obj = BadProperty()
# obj.name = "Test"  # RecursionError: maximum recursion depth exceeded

The getter and setter must manipulate a different attribute (e.g., _name) to avoid this. The property itself (name) is the interface, while the “private” attribute (_name) is the implementation detail where the data is actually stored.

Best Practices and Common Patterns

  1. Validation: Setters are the ideal place for data validation. You can ensure the object’s internal state remains consistent by checking type, value ranges, or format before allowing an assignment.
  2. Computed Attributes: Properties are perfect for attributes derived from other attributes. For example, a full_name property could return f"{self.first_name} {self.last_name}", and a setter could parse a full name string and split it into first_name and last_name.
  3. Lazy Evaluation: A getter can compute a value only when it’s accessed for the first time and then cache it in a private attribute, improving performance for expensive operations.
  4. Backward Compatibility: Properties allow you to change a simple public attribute into a managed one without breaking the existing interface. Code that once used obj.attribute will continue to work, unaware of the new validation or logic behind the scenes.
  5. Read-Only Properties: To create a read-only property, simply define a getter without a corresponding setter. Attempts to assign a value will result in an AttributeError: can't set attribute.
class ReadOnlyExample:
    def __init__(self, constant_value):
        self._constant = constant_value

    @property
    def constant(self):
        return self._constant

obj = ReadOnlyExample(42)
print(obj.constant)  # Output: 42
obj.constant = 100   # AttributeError: can't set attribute