When I first started working with Python classes, some of the most confusing topics were getters, setters, and @property
. There are plenty of tutorials on how to use them, but very few actually explain why do we need them or what problem do they solve. So, I thought I’d write a dedicated post covering what they are and the problems they solve. Let’s get to it.
As always, if you find this post helpful, press the ‘clap’ button. It means a lot to me and helps me know you enjoy this type of content.

Python Classes
Before diving in, let's have a quick look at a Python class. Here’s a simple example of a Person
class with two attributes name
and age
.
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
I'm going to create an instance of the class called p1
, passing a name and age as shown below.
p1 = Person('John', 36)
And as you might have guessed, I can access the attributes name
and age
like this.
p1.name # Output: 'John'
p1.age # Output: 36
I can also modify the attributes using the same syntax.
p1.name = 'Max'
p1.name # Output: 'Max'
This is pretty basic and works just fine. So, what's the issue, you may ask?
Some Problems
Let's say down the line, you're making some improvements to your code and realize you need some control over how attributes are assigned. You don’t want someone to accidentally assign an integer to name
or a string to age
. You also want to make sure the age doesn’t go above 150, for example. So, what can we do here?
In some programming languages, there’s a concept of private variables, which means certain attributes can only be accessed or modified through specific methods. Python doesn’t have true private variables, even if you prepend an attribute with an underscore (_
). This is just a convention, not actual enforcement.
For example, as of now, nothing is stopping me from doing this.
p1.name = 10
p1.age = 'Alice'
This obviously doesn’t make sense, so let’s say we decide to put some logic in place to prevent it. We could change our code to introduce getter and setter methods, which allow us to control how attributes are accessed and modified. Here’s how we can do it.
class Person:
def __init__(self, name, age):
self._name = name
self._age = age
def get_name(self):
return self._name
def set_name(self, name):
if not isinstance(name, str):
raise ValueError('Name must be a string')
else:
self._name = name
def get_age(self, age):
self._age = age
def set_age(self):
if not isinstance(self._age, int):
raise ValueError('Age must be an integer')
else:
return self._age
The key change here is that instead of directly assigning values to name
and age
, we are now using _name
and _age
with getter and setter methods. This allows us to add checks when setting values. For example, in set_name
, we ensure that only strings are assigned, and in get_age
, we check that the stored value is an integer.
Now, we can initialize an object as usual, but the only way to access or modify the attributes is through the class methods.
p1 = Person('John', 36)
p1.get_name() # Output: 'John'
p1.set_name('Max')
p1.get_name() # Output: 'Max'
However, we can no longer access attributes directly like p1.name
. If we try, we’ll get an error:
p1.name # Output: AttributeError: 'Person' object has no attribute 'name'
That said, as mentioned earlier, Python doesn’t have true private variables. If someone really wants to access _name
, they can still do it like this.
p1._name # Output: 'Max'
Even though the underscore is a convention signaling that it’s intended to be private, Python doesn’t enforce it. Of course, nothing is stopping someone from assigning values directly like this:
p1._name = 55
This will work without any errors, but generally, users should understand that if an attribute is prepended with an underscore (_
), it is meant to be treated as private and should not be accessed or modified directly.
With this approach, there’s still another problem. Users can still assign values using p1.name = 'Max'
or p1.age = 30
. When they do this, Python doesn’t update _name
or _age
. Instead, it creates new attributes name
and age
on the object, completely bypassing our validation logic.
This means that if we access p1.get_name()
, it will return _name
, but if we access p1.name
, it will return the newly created attribute, leading to inconsistent behavior. This is an issue because users may unknowingly bypass the validation we implemented.
For example, if someone does p1.name = 10
, Python will simply create a new attribute name
with the value 10
, and our setter method will never be called. This completely defeats the purpose of having getter and setter methods in the first place.

What Does Python Recommend?
Even though the previous method works, Python actually recommends using dot notation to access attributes. You might not even have a use case for getters or setters when you're first developing your code. The more Pythonic way is to use dot notation, like this.
p1.name
p1.age
But the problem arises when, down the line, you realize you need to add some logic or checks when setting values. If you've been using dot notation and then decide to implement getter and setter methods, you now have to go back and tell all users, "Hey, if you've been using p1.name
to access attributes, from now on, you need to use p1.get_name()
instead."
Is this a good practice? Not really. This change would break a lot of existing code. This is where the @property
decorator comes into play.
@property Decorators
Now let's look at a way to start our class with dot notation attributes while allowing us to introduce getters and setters later without breaking existing code.
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
So, we can start our code like this (shown above), and users will naturally use p1.name
to access attributes. Later, if we need to add validation or logic, we can easily introduce the @property
decorator (shown below) without breaking any existing code.
class Person:
def __init__(self, name, age):
self._name = name
self._age = age
@property
def name(self):
return self._name
@name.setter
def name(self, name):
if not isinstance(name, str):
raise ValueError('Name must be a string')
else:
self._name = name
@property
def age(self):
return self._age
@age.setter
def age(self, age):
if not isinstance(age, int):
raise ValueError('Age must be an integer')
else:
self._age = age
Instead of creating separate get_name
and set_name
methods, we are now using @property
and @property.setter
to define how the attributes should be accessed and modified.
- The
@property
decorator makesname
andage
readable using dot notation, even though they are internally stored as_name
and_age
. - The
@name.setter
and@age.setter
decorators define how values should be assigned, allowing us to add validation checks while keeping dot notation usage intact.
This means we can still initialize a Person
object, access attributes with dot notation, and assign values the same way we did before. But now, when we assign values, the setter method is automatically called.
p1 = Person('John', 36)
# Accessing attributes
print(p1.name) # Output: 'John'
print(p1.age) # Output: 36
# Modifying attributes
p1.name = 'Max'
print(p1.name) # Output: 'Max'
# Assigning an invalid value
p1.age = 'Alice' # Output: ValueError: Age must be an integer
Unlike before, assigning a value now actually triggers the setter method inside the class, enforcing our validation rules while still using a clean and simple dot notation.
Enforcing Validation at Initialization
Before wrapping up, let's look at what happens if we try to initialize an object with incorrect values, like an integer for name
and a string for age
. Right now, our class allows this:
p2 = Person(10, 'Alice') # This will not raise an error
Since we were assigning values directly inside __init__
, the setter methods were never triggered. However, we can fix this with a small change:
class Person:
def __init__(self, name, age):
self.name = name # Calls the setter method
self.age = age # Calls the setter method
Instead of assigning values directly to _name
and _age
, we now use self.name = name
and self.age = age
. This change ensures that when an object is initialized, Python automatically calls the setter methods, applying the validation rules we defined earlier.
Now, if we try to initialize an object with invalid values, it will raise an error.
p2 = Person(10, 'Alice')
# Output: ValueError: Name must be a string
Since self.name = name
calls the name
setter method, the validation inside set_name
is triggered immediately, preventing invalid values from being assigned.
