Python @property: Getters and Setters
Learn Python's @property decorator: create getters, setters, deleters, and computed attributes with clean syntax and full validation control.
The @property decorator is Python's built-in mechanism for turning a method into a managed attribute. Instead of writing get_x() and set_x() methods like other languages, you write a normal-looking attribute access (obj.x) while keeping full control over what happens when that attribute is read, written, or deleted.
This chapter covers:
- Why properties exist and when to use them
- Creating a read-only property with
@property - Adding a setter with
@<name>.setter - Adding a deleter with
@<name>.deleter - Computed (derived) properties
- Upgrading a plain attribute to a property without breaking callers
- The
property()built-in function — the decorator's underlying mechanism - How properties work as descriptors (brief look under the hood)
- Common gotchas
Before reading, make sure you are comfortable with Python classes and objects. Properties are a key tool for Python encapsulation. For class-level and static methods, see @staticmethod and @classmethod.
Why Properties Exist
Consider a class that stores a temperature in Celsius. A naive implementation exposes the internal value directly:
class Temperature:
def __init__(self, celsius):
self.celsius = celsius
t = Temperature(25)
t.celsius = -5000 # nothing stops this — physically impossibleThe problem: nothing prevents callers from setting a temperature below absolute zero (−273.15 °C). You could add a set_celsius() method with validation, but then callers must change their code from t.celsius = 100 to t.set_celsius(100) — a breaking API change.
@property solves this cleanly. You keep the t.celsius = 100 syntax while adding a layer of control behind the scenes.
Basic Getter: Read-Only Access
The simplest use of @property is a read-only attribute backed by a private variable:
class Temperature:
def __init__(self, celsius):
self._celsius = celsius # store in a private attribute
@property
def celsius(self):
return self._celsiusThe @property decorator makes celsius look like a plain attribute to the caller:
t = Temperature(25)
print(t.celsius) # 25 — no parentheses; Python calls the getter automaticallyBecause there is no setter, attempting to assign to it raises an error:
t.celsius = 30
# AttributeError: property 'celsius' of 'Temperature' object has no setterThis is the correct way to model a value that should only be set at construction time or through specific methods.
Adding a Setter with Validation
Decorate a second method with @<property_name>.setter to handle writes:
class Temperature:
def __init__(self, celsius):
self._celsius = celsius
@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 = valueNow both read and write work with plain attribute syntax:
t = Temperature(25)
print(t.celsius) # 25
t.celsius = 100
print(t.celsius) # 100
t.celsius = -300 # ValueError: Temperature below absolute zeroKey rule: the setter and getter must share the same name (celsius in both cases). The decorator @celsius.setter links the new method to the existing celsius property object.
Computed Properties
A property does not have to correspond to a stored attribute at all. It can compute a value on the fly from other data:
class Temperature:
def __init__(self, celsius):
self._celsius = celsius
@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 + 32fahrenheit has no backing variable — it derives its value from _celsius every time it is read:
t = Temperature(0)
print(t.fahrenheit) # 32.0
t.celsius = 100
print(t.fahrenheit) # 212.0Because there is no @fahrenheit.setter, trying to write t.fahrenheit = 100 raises an AttributeError. Computed properties are naturally read-only unless you explicitly add a setter.
A real-world computed property example
class Rectangle:
def __init__(self, width, height):
self._width = width
self._height = height
@property
def width(self):
return self._width
@width.setter
def width(self, value):
if value <= 0:
raise ValueError('Width must be positive')
self._width = value
@property
def height(self):
return self._height
@height.setter
def height(self, value):
if value <= 0:
raise ValueError('Height must be positive')
self._height = value
@property
def area(self):
return self._width * self._height # computed; no setter
@property
def perimeter(self):
return 2 * (self._width + self._height) # computed; no setter
r = Rectangle(4, 5)
print(r.area) # 20
print(r.perimeter) # 18
r.width = 10
print(r.area) # 50
r.width = -1 # ValueError: Width must be positiveAdding a Deleter
The @<property_name>.deleter decorator lets you run code when the caller uses del obj.attr:
class Temperature:
def __init__(self, celsius):
self._celsius = celsius
@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
@celsius.deleter
def celsius(self):
print('Deleting celsius')
del self._celsius
t = Temperature(25)
del t.celsius # Deleting celsius
print(t.celsius) # AttributeError: 'Temperature' object has no attribute '_celsius'Deleters are less commonly used than getters and setters. They are useful when:
- Removing a cached value to force recomputation on the next access.
- Explicitly releasing resources tied to an attribute.
- Enforcing that once deleted, a value cannot be re-read without re-assigning.
Upgrading a Plain Attribute to a Property
One of the biggest practical benefits of @property is that you can start with a plain public attribute and add validation later without changing any calling code. This is sometimes called the uniform access principle.
# Version 1 — plain attribute, no validation
class Circle:
def __init__(self, radius):
self.radius = radius
c = Circle(5)
print(c.radius) # 5
c.radius = 10 # works, but nothing stops c.radius = -1Later you need validation. With @property you can add it without touching the callers:
# Version 2 — property with validation; public interface unchanged
import math
class Circle:
def __init__(self, radius):
self.radius = radius # this now calls the setter
@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
@property
def area(self):
return math.pi * self._radius ** 2
c = Circle(5)
print(c.radius) # 5
print(f'{c.area:.4f}') # 78.5398
c.radius = 10
print(c.radius) # 10
c.radius = -1 # ValueError: Radius cannot be negativeAny existing code that reads or writes c.radius continues to work without modification.
The property() Built-in Function
@property is syntactic sugar for the built-in property() function. These two definitions are equivalent:
# --- decorator style (recommended) ---
class Person:
def __init__(self, age):
self._age = age
@property
def age(self):
return self._age
@age.setter
def age(self, value):
if not isinstance(value, int) or value < 0:
raise ValueError('Age must be a non-negative integer')
self._age = value# --- property() style (explicit) ---
class Person:
def __init__(self, age):
self._age = age
def _get_age(self):
return self._age
def _set_age(self, value):
if not isinstance(value, int) or value < 0:
raise ValueError('Age must be a non-negative integer')
self._age = value
def _del_age(self):
del self._age
age = property(_get_age, _set_age, _del_age, 'The person\'s age in years')property(fget, fset, fdel, doc) takes up to four arguments: a getter function, a setter function, a deleter function, and a docstring. Any of them can be None.
p = Person(30)
print(p.age) # 30
p.age = 31
print(p.age) # 31
print(Person.age.__doc__) # The person's age in yearsThe decorator form is cleaner and the standard recommendation. The explicit property() call is useful when you want to pass the docstring without a multi-line decorator block, or when the accessor functions already exist by another name.
How Properties Work: A Brief Look at Descriptors
Internally, property is a descriptor — an object that defines __get__, __set__, and __delete__ on the class. When Python looks up obj.attr, it checks whether the attribute on the class is a descriptor and, if so, calls its __get__ instead of returning the value directly.
You can see this by inspecting the property object on the class:
class Square:
def __init__(self, side):
self._side = side
@property
def side(self):
return self._side
@side.setter
def side(self, value):
if value < 0:
raise ValueError('Side must be non-negative')
self._side = value
print(type(Square.side)) # <class 'property'>
print(Square.side.fget) # <function Square.side at 0x...>
print(Square.side.fset) # <function Square.side at 0x...>
print(Square.side.fdel) # NoneThis is why reading Square.side returns the property object itself (descriptor accessed on the class), while reading s.side on an instance triggers __get__ and returns the integer. The descriptor protocol is the same mechanism used by classmethod, staticmethod, and functions themselves. For a deeper dive, see Python magic methods.
Common Gotchas
Infinite recursion: forgetting the underscore
A very common mistake is using the same name for both the property and the backing attribute:
class Bad:
@property
def value(self):
return self.value # RecursionError! This calls the getter again
@value.setter
def value(self, v):
self.value = v # RecursionError! This calls the setter againAlways store the backing value in a different name, by convention prefixed with an underscore:
class Good:
@property
def value(self):
return self._value # reads the private attribute
@value.setter
def value(self, v):
self._value = v # writes the private attributeSetter defined before getter
The setter decorator @celsius.setter references the celsius property object, which must exist first. Always define the getter (@property) before the setter and deleter in the class body.
__init__ calls the setter automatically
When you write self.radius = radius inside __init__, Python calls the setter (if one exists). This is usually what you want — validation runs at construction time too. But it means your setter must handle the initial assignment gracefully:
class Circle:
def __init__(self, radius):
self.radius = radius # triggers the setter — validation applies here too
@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
Circle(-1) # ValueError: Radius cannot be negativeProperties are class-level, not instance-level
You cannot add a property to a single instance the way you can with regular attributes. Properties are defined on the class and apply to all instances. If you need per-instance attribute customization, see Python dataclasses or use a __slots__-based approach.
Quick Reference
| Syntax | What it does |
|---|---|
@property | Defines the getter; attribute becomes read-only until a setter is added |
@<name>.setter | Defines the setter; attribute becomes readable and writable |
@<name>.deleter | Defines the deleter; del obj.attr triggers this method |
property(fget, fset, fdel, doc) | Equivalent built-in without decorator syntax |
ClassName.prop.fget | The underlying getter function |
ClassName.prop.fset | The underlying setter function (None if no setter) |
ClassName.prop.fdel | The underlying deleter function (None if no deleter) |