Encapsulation in Python
By the Syncfusion DevTutorials team · Last updated
Keeping data inside the class: data hiding, getters and setters, validation, and why Python uses @property instead.
Think about a bank account.
Your balance is a number the bank holds. You cannot reach in and set it to a million. You deposit, you withdraw, and the bank checks each one: enough money, a sensible amount, a real account.
The number is protected, and every way of changing it goes through a check.
Encapsulation is that idea in code: keep the data behind the class, and let it be changed only through methods that can say no.
A value that protects itself
class Student:
def __init__(self, name):
self.name = name
self._marks = 0
def set_marks(self, value):
if not 0 <= value <= 100:
raise ValueError("marks must be between 0 and 100")
self._marks = value
def get_marks(self):
return self._marks
asha = Student("Asha")
asha.set_marks(78)
print(asha.get_marks())
try:
asha.set_marks(150)
except ValueError as error:
print("Refused:", error)
Encapsulation means keeping data inside the class and controlling every way it can be changed.
What You Will Learn
In this lesson, you will learn:
- What encapsulation is, and what goes wrong without it
- How Python marks data as internal, and what that does and does not stop
- How to write getters and setters, and how to check values on the way in
- Why Python prefers
@propertyto the getter and setter methods other languages use
By the end of this lesson, you will be able to protect class data, write methods that check and validate changes, use properties to control access, and understand why Python's approach to encapsulation is better than the getter-setter methods found in other languages.
Encapsulation
What Is Encapsulation?
Encapsulation means keeping data inside a class and controlling how it is changed. It has two parts:
- The data and the methods that work on it live together in one class
- The data is only reached through those methods, not directly
The second part is what matters: by forcing all changes through methods, you can check each one. This prevents impossible values from ever entering an object.
Data Hiding
When you write a class without encapsulation, anything can put anything into an object. Watch what happens:
Nothing stops a nonsense value
class Student:
def __init__(self, name, marks):
self.name = name
self.marks = marks
asha = Student("Asha", 78)
asha.marks = 5000
asha.marks = -20
asha.marks = "absent"
print(asha.name, asha.marks)
Three impossible values went in without a word. The object is now broken, and the program will fail somewhere far away — in a function that tries to add up marks, or draw a chart, or compare a grade. The failure will not be at the line that caused the damage; it will be far away, making the bug hard to find.
This separation between the bad line and the failure is the real problem:
The failure happens somewhere else entirely
class Student:
def __init__(self, name, marks):
self.name = name
self.marks = marks
asha = Student("Asha", 78)
asha.marks = "absent"
print("The bad assignment caused no error at all")
print("Average:", asha.marks / 1)
The bad assignment happened on line 7 and caused no error. Line 9 printed normally. The error appeared on line 11, four lines later, when the code tried to divide text by 1. By then, it is hard to trace the problem back to its source.
Benefits of Encapsulation
| Benefit | What it means |
|---|---|
| Values stay valid | every change goes through a check |
| Errors arrive early | the bad line fails, not some later one |
| Freedom to change | the inside can be rewritten without breaking callers |
| One place to look | all the rules about marks live in one class |
| A smaller surface | callers see a few methods, not every attribute |
Encapsulation Examples
Here is a class that protects itself. Every change goes through a method, so the balance can never go negative and the history can never skip a transaction:
A class that keeps itself consistent
class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self._balance = balance
self._history = []
def deposit(self, amount):
if amount <= 0:
raise ValueError("deposit must be more than zero")
self._balance += amount
self._history.append(f"deposit {amount}")
def withdraw(self, amount):
if amount > self._balance:
raise ValueError("not enough money")
self._balance -= amount
self._history.append(f"withdraw {amount}")
def balance(self):
return self._balance
def statement(self):
return list(self._history)
account = BankAccount("Asha", 100)
account.deposit(50)
account.withdraw(30)
print("Balance:", account.balance())
print("History:", account.statement())
try:
account.withdraw(1000)
except ValueError as error:
print("Refused:", error)
Notice that statement() returns list(self._history) — a copy of the history, not the history itself. If we handed back the real list, a caller could change it from outside, breaking the encapsulation.
Here is why that matters:
Why the copy matters
class BankAccount:
def __init__(self):
self._history = ["deposit 50"]
def leaky_statement(self):
return self._history
def safe_statement(self):
return list(self._history)
account = BankAccount()
account.leaky_statement().append("deposit 1000000")
print("After the leaky one:", account.safe_statement())
account.safe_statement().append("deposit 1000000")
print("After the safe one: ", account.safe_statement())
The first call reaches the real list and changes it. The second call gets a copy, changes the copy, and leaves the account alone. This shows why the copy is essential.
Getters and Setters
Getter Methods
A getter is a method that hands back a value. The simplest getters just return what is stored:
A getter
class Student:
def __init__(self, name, marks):
self._name = name
self._marks = marks
def get_name(self):
return self._name
def get_marks(self):
return self._marks
asha = Student("Asha", 78)
print(asha.get_name())
print(asha.get_marks())
A getter can also work something out rather than just return a stored value:
A getter that calculates
class Student:
def __init__(self, marks):
self._marks = marks
def get_average(self):
return round(sum(self._marks) / len(self._marks), 1)
def get_grade(self):
average = self.get_average()
return "A" if average >= 80 else "B" if average >= 60 else "C"
asha = Student([78, 85, 60])
print(asha.get_average())
print(asha.get_grade())
Setter Methods
A setter changes a value. More importantly, it is where you check the value before storing it:
A setter that checks
class Student:
def __init__(self, name):
self._name = name
self._marks = 0
def set_marks(self, value):
if not isinstance(value, int):
raise TypeError("marks must be a whole number")
if not 0 <= value <= 100:
raise ValueError("marks must be between 0 and 100")
self._marks = value
def get_marks(self):
return self._marks
asha = Student("Asha")
asha.set_marks(78)
print(asha.get_marks())
for bad in [150, -20, "absent"]:
try:
asha.set_marks(bad)
except (ValueError, TypeError) as error:
print(f"{bad!r} refused: {error}")
print("Marks are still:", asha.get_marks())
Three bad values were refused with clear error messages. The object never held an impossible value, not even for an instant.
Data Validation
The key idea is that the checking lives in one place, so it cannot be forgotten. Every way of changing the data must go through the setter:
One rule, enforced everywhere
class Student:
def __init__(self, name, marks=0):
self._name = name
self._marks = 0
self.set_marks(marks)
def set_marks(self, value):
if not 0 <= value <= 100:
raise ValueError(f"marks must be 0 to 100, got {value}")
self._marks = value
def raise_marks(self, points):
self.set_marks(self._marks + points)
def get_marks(self):
return self._marks
asha = Student("Asha", 95)
print(asha.get_marks())
asha.raise_marks(3)
print(asha.get_marks())
try:
asha.raise_marks(10)
except ValueError as error:
print("Refused:", error)
Notice that __init__ calls self.set_marks(marks) instead of assigning directly. This means the check runs at creation. Similarly, raise_marks also goes through the setter. So there is only one place where marks can be set, and the rule is enforced everywhere.
Controlling Attribute Access
Getters and setters as written above are how Java and C# do it. Python has a better approach.
The problem with explicit getters and setters is noise. Every value needs two methods, and every read or write needs brackets:
The verbose way
class Student:
def __init__(self, marks):
self._marks = marks
def get_marks(self):
return self._marks
def set_marks(self, value):
if not 0 <= value <= 100:
raise ValueError("marks must be 0 to 100")
self._marks = value
asha = Student(78)
asha.set_marks(asha.get_marks() + 5)
print(asha.get_marks())
@property lets you keep the checking but use plain attribute syntax:
The Python way
class Student:
def __init__(self, marks):
self.marks = marks
@property
def marks(self):
return self._marks
@marks.setter
def marks(self, value):
if not 0 <= value <= 100:
raise ValueError("marks must be 0 to 100")
self._marks = value
asha = Student(78)
asha.marks += 5
print(asha.marks)
To the reader, asha.marks looks like a plain attribute. Behind the scenes, it runs a method. asha.marks += 5 reads the getter and writes through the setter, so the check still runs.
The stored value must not have the same name as the property. If you write self.marks = value inside the setter, you are assigning to the property, which calls the setter again, which calls the setter again, until Python stops with RecursionError. By convention, store it as self._marks.
Notice the constructor says self.marks = marks, not self._marks = marks. Using the property means the check runs at creation. If a bad value is passed to the constructor, it is caught there:
The check applies at creation too
class Student:
def __init__(self, marks):
self.marks = marks
@property
def marks(self):
return self._marks
@marks.setter
def marks(self, value):
if not 0 <= value <= 100:
raise ValueError("marks must be 0 to 100")
self._marks = value
Student(150)
A property with a getter and no setter is read-only. This is how you expose a value that is worked out from others:
A read-only property
class Student:
def __init__(self, marks):
self._marks = marks
@property
def average(self):
return round(sum(self._marks) / len(self._marks), 1)
asha = Student([78, 85, 60])
print(asha.average)
asha.average = 100
AttributeError — average can be read and not set, which is correct. It is worked out from the marks, so setting it would make no sense.
The best part about @property is that you can add it later without breaking any code that uses the class:
Adding a rule without breaking callers
class Student:
def __init__(self, marks):
self.marks = marks
@property
def marks(self):
return self._marks
@marks.setter
def marks(self, value):
if not 0 <= value <= 100:
raise ValueError("marks must be 0 to 100")
self._marks = value
asha = Student(78)
print(asha.marks)
asha.marks = 85
print(asha.marks)
Every line using asha.marks is written exactly as it was when marks was a plain attribute. Python code can start with plain attributes and add a property only when a rule appears. There is no cost to waiting.
Start with a plain public attribute. Add @property the day a rule appears. Writing getters and setters for every value "just in case" is a habit from languages where changing your mind later would break every caller.
get_x() / set_x() | @property | |
|---|---|---|
| Reading | obj.get_x() | obj.x |
| Writing | obj.set_x(5) | obj.x = 5 |
| Checking on write | yes | yes |
| Read-only values | leave out the setter | leave out the setter |
| Can be added later | no — callers must change | yes — callers are untouched |
| Usual in Python | no | yes |
Here is everything together:
Everything together
class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self._balance = 0
self.balance = balance
@property
def balance(self):
return self._balance
@balance.setter
def balance(self, value):
if value < 0:
raise ValueError("balance cannot be negative")
self._balance = value
@property
def is_overdrawn(self):
return self._balance == 0
def deposit(self, amount):
if amount <= 0:
raise ValueError("deposit must be more than zero")
self.balance = self._balance + amount
account = BankAccount("Asha", 100)
print(account.balance, account.is_overdrawn)
account.deposit(50)
print(account.balance)
try:
account.balance = -5
except ValueError as error:
print("Refused:", error)
balance is checked on every write, is_overdrawn is read-only and worked out when asked, and the caller writes plain attribute syntax throughout.
Common mistakes
Each row is one mistake. The middle column is what you will actually see on screen.
| Mistake | What you see | Why it happens | Fix |
|---|---|---|---|
| A public attribute with rules about it | Impossible values stored, failing later | Nothing checks a plain assignment | Use @property with a setter |
self._marks = marks in __init__ with a property | Bad values accepted at creation | It skips the setter | self.marks = marks |
| Naming the property and the attribute the same | RecursionError: maximum recursion depth exceeded | The setter calls itself for ever | Store in self._marks |
| Returning the real list from a getter | A caller changes your data | Lists are shared, not copied | Return list(self._items) |
Expecting _marks to be protected | Anyone can read and write it | One underscore is a convention only | Accept it, or use a property |
Expecting __marks to be secure | _Class__marks is reachable | Name mangling renames, it does not lock | Treat it as a convention |
| Getters and setters for every attribute | Noisy code with no benefit | A habit from other languages | Start with plain attributes |
| A setter that does not check anything | All the noise, none of the protection | It just copies the value | Either check, or use a plain attribute |
| Validating in several places | One route gets missed | The rule is written more than once | Make every route go through the setter |
What to Remember
- Encapsulation means keeping data inside the class and controlling every way it can be changed.
- Without it, an impossible value goes in silently and the failure appears somewhere else entirely.
- One underscore means "internal, please leave alone", and Python does not enforce it.
- A getter hands a value back; a setter changes it and is the place to check it.
- Put the rule in one place and route every change through it, including the constructor.
@propertyis the Python way: attribute syntax outside, a method inside.- The stored value needs a different name —
self._marks— or the setter calls itself for ever. - With a property, write
self.marks = marksin__init__so creation is checked too. - A property with no setter is read-only, which is right for a value worked out from others.
- Start with plain attributes and add a property when a rule appears. Callers never have to change.
- Return
list(self._items)from a getter, so a caller cannot change your data from outside.
Next: Advanced OOP