Intermediate

You have built a Python class with a handful of attributes, and everything works fine — until a user sets age to -5 or email to an empty string. Suddenly your downstream code breaks in confusing ways because nobody validated the data at the point where it was assigned. The quick fix is writing explicit getter and setter methods like Java, but that makes your clean Python code look bloated and ugly.

Python has a built-in solution for this: the @property decorator. It lets you define methods that look and feel like regular attribute access to the caller, while secretly running validation, computation, or logging behind the scenes. Combined with descriptors — the lower-level protocol that powers @property under the hood — you can build reusable, self-validating attribute types that work across any class.

In this article, we will start with a quick example showing @property in action, then explain what properties and descriptors actually are, walk through getters, setters, and deleters, build custom descriptors, and finish with a real-life example that ties everything together. By the end, you will know how to protect your class attributes without sacrificing Python’s clean syntax.

Python Property Decorator: Quick Example

Here is the shortest useful example of @property — a Temperature class that stores Celsius internally but lets you read Fahrenheit as if it were a normal attribute.

# quick_example.py
class Temperature:
    def __init__(self, celsius):
        self.celsius = celsius

    @property
    def fahrenheit(self):
        return self.celsius * 9 / 5 + 32

temp = Temperature(100)
print(temp.fahrenheit)
print(temp.celsius)
temp.celsius = 0
print(temp.fahrenheit)

Output:

212.0
100
32.0

Notice how temp.fahrenheit looks like a regular attribute access — no parentheses, no method call syntax. But behind the scenes, Python is running the fahrenheit method every time you access it. This is the core idea behind properties: methods disguised as attributes.

The real power shows up when you add a setter, which we will cover in the sections below.

What Are Properties and Why Use Them?

A property in Python is a special kind of attribute that delegates access to methods. When you read the attribute, Python calls a getter method. When you write to it, Python calls a setter method. When you delete it, Python calls a deleter method. The caller never knows methods are involved — they just use normal dot notation.

This matters because it lets you start with simple public attributes and add validation or computation later without changing the API. In languages like Java, you must write getX() and setX() methods from day one “just in case”. In Python, you can freely use plain attributes until you actually need control, then switch to properties without breaking any calling code.

ApproachSyntaxWhen to Use
Plain attributeobj.x = 5No validation needed, simple storage
Propertyobj.x = 5 (calls setter)Need validation, computation, or logging
Descriptorobj.x = 5 (calls __set__)Reusable validation across multiple classes
Getter/Setter methodsobj.get_x()Avoid in Python — not idiomatic

Properties are the right choice when a single class needs managed attributes. Descriptors are the right choice when you want to reuse that management logic across many classes or attributes.

Creating a Property Getter

The simplest property is a read-only computed attribute. You decorate a method with @property and it becomes accessible as an attribute. This is useful for values that are derived from other attributes and should always stay in sync.

# property_getter.py
class Circle:
    def __init__(self, radius):
        self._radius = radius

    @property
    def radius(self):
        return self._radius

    @property
    def area(self):
        return 3.14159 * self._radius ** 2

    @property
    def circumference(self):
        return 2 * 3.14159 * self._radius

circle = Circle(5)
print(f"Radius: {circle.radius}")
print(f"Area: {circle.area:.2f}")
print(f"Circumference: {circle.circumference:.2f}")

Output:

Radius: 5
Area: 78.54
Circumference: 31.42

The area and circumference properties are computed on every access from the stored _radius value. They cannot go out of sync because there is no separate stored value to drift. The underscore prefix on _radius is a Python convention meaning “this is internal, use the property instead”.

Adding a Property Setter

A getter alone makes the attribute read-only. To allow assignment with validation, add a setter using the @name.setter decorator. The setter receives the value being assigned and can validate, transform, or reject it before storing.

# property_setter.py
class User:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    @property
    def name(self):
        return self._name

    @name.setter
    def name(self, value):
        if not isinstance(value, str) or len(value.strip()) == 0:
            raise ValueError("Name must be a non-empty string")
        self._name = value.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("Age must be an integer between 0 and 150")
        self._age = value

user = User("Alice", 30)
print(f"{user.name}, age {user.age}")

user.name = "  Bob  "
print(f"Name after update: {user.name}")

user.age = 25
print(f"Age after update: {user.age}")

try:
    user.age = -5
except ValueError as e:
    print(f"Error: {e}")

try:
    user.name = ""
except ValueError as e:
    print(f"Error: {e}")

Output:

Alice, age 30
Name after update: Bob
Age after update: 25
Error: Age must be an integer between 0 and 150
Error: Name must be a non-empty string

The validation runs every time you assign to user.name or user.age — including inside __init__. This is a key benefit: the constructor uses self.name = name (not self._name = name), so the validation runs on object creation too.

Adding a Property Deleter

The third and least common piece of the property puzzle is the deleter. It runs when someone uses del obj.attribute. This is useful for cleanup logic or for resetting an attribute to a default state.

# property_deleter.py
class CachedData:
    def __init__(self, raw_data):
        self._raw_data = raw_data
        self._processed = None

    @property
    def processed(self):
        if self._processed is None:
            print("Processing data (expensive operation)...")
            self._processed = sorted(set(self._raw_data))
        return self._processed

    @processed.deleter
    def processed(self):
        print("Clearing cache")
        self._processed = None

data = CachedData([3, 1, 4, 1, 5, 9, 2, 6, 5])
print(data.processed)
print(data.processed)
del data.processed
print(data.processed)

Output:

Processing data (expensive operation)...
[1, 2, 3, 4, 5, 6, 9]
[1, 2, 3, 4, 5, 6, 9]
Clearing cache
Processing data (expensive operation)...
[1, 2, 3, 4, 5, 6, 9]

The first access triggers processing. The second access returns the cached result. After del data.processed, the cache clears, and the next access reprocesses. This pattern is called “lazy evaluation with cache invalidation” and is common in data pipelines.

Understanding Descriptors

Properties are actually built on top of a more fundamental Python mechanism called the descriptor protocol. A descriptor is any object that defines __get__, __set__, or __delete__ methods. When Python looks up an attribute and finds a descriptor on the class, it calls those methods instead of returning the descriptor object itself.

This is what makes descriptors powerful: you define the attribute behavior once in a separate class, then reuse it across any number of attributes in any number of classes. Properties are single-use descriptors. Custom descriptors are reusable ones.

# descriptor_basics.py
class PositiveNumber:
    def __init__(self, name):
        self.name = name

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return obj.__dict__.get(self.name, 0)

    def __set__(self, obj, value):
        if not isinstance(value, (int, float)):
            raise TypeError(f"{self.name} must be a number")
        if value <= 0:
            raise ValueError(f"{self.name} must be positive")
        obj.__dict__[self.name] = value

    def __delete__(self, obj):
        del obj.__dict__[self.name]

class Product:
    price = PositiveNumber("price")
    weight = PositiveNumber("weight")

    def __init__(self, name, price, weight):
        self.name = name
        self.price = price
        self.weight = weight

item = Product("Widget", 9.99, 0.5)
print(f"{item.name}: ${item.price}, {item.weight}kg")

try:
    item.price = -10
except ValueError as e:
    print(f"Error: {e}")

try:
    item.weight = 'heavy'
except TypeError as e:
    print(f"Error: {e}")

Output:

Widget: $9.99, 0.5kg
Error: price must be positive
Error: weight must be a number

The PositiveNumber descriptor is defined once and used for both price and weight. You could use it in any class that needs positive numeric attributes.

Building Custom Descriptors

Let us build a more sophisticated descriptor that validates string attributes with configurable constraints: minimum length, maximum length, and an optional regex pattern.

# custom_descriptor.py
import re

class ValidatedString:
    def __init__(self, name, min_length=0, max_length=None, pattern=None):
        self.name = name
        self.min_length = min_length
        self.max_length = max_length
        self.pattern = re.compile(pattern) if pattern else None

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return obj.__dict__.get(self.name, "")

    def __set__(self, obj, value):
        if not isinstance(value, str):
            raise TypeError(f"{self.name} must be a string")
        value = value.strip()
        if len(value) < self.min_length:
            raise ValueError(
                f"{self.name} must be at least {self.min_length} characters"
            )
        if self.max_length and len(value) > self.max_length:
            raise ValueError(
                f"{self.name} must be at most {self.max_length} characters"
            )
        if self.pattern and not self.pattern.match(value):
            raise ValueError(
                f"{self.name} does not match required pattern"
            )
        obj.__dict__[self.name] = value

class Registration:
    username = ValidatedString("username", min_length=3, max_length=20,
                                pattern=r"^[a-zA-Z0-9_]+$")
    email = ValidatedString("email", min_length=5,
                             pattern=r"^[^@]+@[^@]+\.[^@]+$")
    bio = ValidatedString("bio", max_length=200)

    def __init__(self, username, email, bio=""):
        self.username = username
        self.email = email
        self.bio = bio

reg = Registration("alice_dev", "alice@example.com", "Python developer")
print(f"User: {reg.username}, Email: {reg.email}")

try:
    Registration("ab", "test@test.com")
except ValueError as e:
    print(f"Error: {e}")

try:
    Registration("valid_user", "not-an-email")
except ValueError as e:
    print(f"Error: {e}")

Output:

User: alice_dev, Email: alice@example.com
Error: username must be at least 3 characters
Error: email does not match required pattern

This ValidatedString descriptor handles three different kinds of validation in one reusable class.

Property vs Descriptor: When to Use Each

Both properties and descriptors manage attribute access, but they serve different use cases.

CriteriaUse @propertyUse a Descriptor
ReusabilityLogic used in one class onlyLogic reused across multiple classes
Number of attributes1-2 managed attributesMany attributes with same rules
ComplexitySimple get/set/deleteConfigurable validation with parameters
Learning curveEasy to understandRequires understanding __get__/__set__
Typical useComputed values, basic validationForm fields, ORM columns, API models

Start with @property when you first need managed attribute access. If you find yourself copying the same property logic across classes, refactor it into a descriptor.

Real-Life Example: Building a Configuration Manager

Let us build a practical configuration manager that uses both properties and descriptors to validate settings for a web application.

# config_manager.py
class RangeValidator:
    def __init__(self, name, min_val, max_val):
        self.name = name
        self.min_val = min_val
        self.max_val = max_val

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return obj.__dict__.get(self.name, self.min_val)

    def __set__(self, obj, value):
        if not isinstance(value, (int, float)):
            raise TypeError(f"{self.name} must be a number")
        if value < self.min_val or value > self.max_val:
            raise ValueError(
                f"{self.name} must be between {self.min_val} and {self.max_val}"
            )
        obj.__dict__[self.name] = value

class ChoiceValidator:
    def __init__(self, name, choices):
        self.name = name
        self.choices = choices

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return obj.__dict__.get(self.name, self.choices[0])

    def __set__(self, obj, value):
        if value not in self.choices:
            raise ValueError(
                f"{self.name} must be one of {self.choices}"
            )
        obj.__dict__[self.name] = value

class AppConfig:
    port = RangeValidator("port", 1024, 65535)
    max_connections = RangeValidator("max_connections", 1, 10000)
    log_level = ChoiceValidator("log_level",
                                 ["DEBUG", "INFO", "WARNING", "ERROR"])

    def __init__(self, port=8080, max_connections=100, log_level="INFO",
                 app_name="MyApp", debug=False):
        self.port = port
        self.max_connections = max_connections
        self.log_level = log_level
        self._app_name = app_name
        self._debug = debug

    @property
    def debug(self):
        return self._debug

    @debug.setter
    def debug(self, value):
        if not isinstance(value, bool):
            raise TypeError("debug must be a boolean")
        self._debug = value
        if value:
            self.log_level = "DEBUG"
            print("Debug mode enabled -- log level set to DEBUG")

    @property
    def base_url(self):
        protocol = "http" if self._debug else "https"
        return f"{protocol}://localhost:{self.port}"

    def summary(self):
        return (
            f"App: {self._app_name}\n"
            f"URL: {self.base_url}\n"
            f"Port: {self.port}\n"
            f"Max Connections: {self.max_connections}\n"
            f"Log Level: {self.log_level}\n"
            f"Debug: {self.debug}"
        )

config = AppConfig(port=3000, app_name="TutorialApp")
print(config.summary())
print()

config.debug = True
print(f"URL changed to: {config.base_url}")
print()

try:
    config.port = 80
except ValueError as e:
    print(f"Port error: {e}")

try:
    config.log_level = "TRACE"
except ValueError as e:
    print(f"Log level error: {e}")

config.max_connections = 500
print(f"Updated max connections: {config.max_connections}")

Output:

App: TutorialApp
URL: https://localhost:3000
Port: 3000
Max Connections: 100
Log Level: INFO
Debug: False

Debug mode enabled -- log level set to DEBUG
URL changed to: http://localhost:3000

Port error: port must be between 1024 and 65535
Log level error: log_level must be one of ['DEBUG', 'INFO', 'WARNING', 'ERROR']
Updated max connections: 500

This configuration manager uses descriptors for reusable numeric and choice validation, and properties for one-off computed values and side effects.

Frequently Asked Questions

Can I use @property with __slots__?

Yes, properties work with __slots__, but you need to include the private attribute name (like _name) in your slots tuple, not the property name. The property itself lives on the class, not the instance, so it does not need a slot.

What happens if I define a property without a setter and try to assign to it?

Python raises an AttributeError with the message “property ‘x’ of ‘ClassName’ object has no setter”. This makes it clear that the attribute is read-only.

Do descriptors work with inheritance?

Yes. If a parent class defines a descriptor attribute, subclasses inherit it. You can override it by defining a new descriptor or property with the same name in the subclass. The method resolution order (MRO) determines which descriptor Python finds first.

What is the difference between a data descriptor and a non-data descriptor?

A data descriptor defines both __get__ and __set__ (or __delete__). A non-data descriptor only defines __get__. The difference matters for attribute lookup priority: data descriptors take precedence over instance __dict__ entries, while non-data descriptors do not.

Is there a performance cost to using properties?

There is a small overhead compared to direct attribute access — Python must call a function instead of looking up a dictionary value. For most applications this is negligible. If you are in a tight loop accessing a property millions of times, consider caching the value in a local variable before the loop.

Conclusion

Python properties and descriptors give you full control over attribute access without sacrificing the clean obj.attr syntax. We covered @property for getters, setters, and deleters, then built custom descriptors like PositiveNumber, ValidatedString, RangeValidator, and ChoiceValidator that can be reused across any class.

The configuration manager example showed how properties and descriptors complement each other: descriptors handle reusable validation patterns, while properties handle one-off computed values and side effects.

For more details, see the official Python documentation on Descriptor HowTo Guide and the property built-in.