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.
| Approach | Syntax | When to Use |
|---|---|---|
| Plain attribute | obj.x = 5 | No validation needed, simple storage |
| Property | obj.x = 5 (calls setter) | Need validation, computation, or logging |
| Descriptor | obj.x = 5 (calls __set__) | Reusable validation across multiple classes |
| Getter/Setter methods | obj.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.
| Criteria | Use @property | Use a Descriptor |
|---|---|---|
| Reusability | Logic used in one class only | Logic reused across multiple classes |
| Number of attributes | 1-2 managed attributes | Many attributes with same rules |
| Complexity | Simple get/set/delete | Configurable validation with parameters |
| Learning curve | Easy to understand | Requires understanding __get__/__set__ |
| Typical use | Computed values, basic validation | Form 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.