Encapsulation
Learn how to protect data in Python classes using private variables, getters, setters, and the property decorator.
Encapsulation
Imagine you walk into a bank and ask the teller to change your account balance directly — no deposit, no withdrawal, just "set my balance to one million". That would never happen. The bank protects its data and only allows changes through controlled, validated processes.
That is exactly what encapsulation is in OOP. It means:
- Hiding internal data from direct outside access
- Controlling how that data is read and changed through methods
Without encapsulation, anyone can reach into your object and change anything directly:
account = BankAccount("Ahmad", 10000)
account.balance = -999999 # nobody stops thisWith encapsulation, you protect the data and force changes to go through proper validation.
The problem — unprotected data
class BankAccount:
def __init__(self, owner, balance):
self.owner = owner
self.balance = balance
account = BankAccount("Ahmad", 10000)
# anyone can do this — no validation, no rules
account.balance = -50000
account.owner = 999 # wrong type entirely
print(account.balance) # -50000 — completely wrongNothing stops incorrect values from being set. This is a serious problem in real applications.
Private variables — name mangling with __
In Python, you make a variable private by adding double underscores before its name: __balance. This is called name mangling — Python internally renames it to _ClassName__balance, making it hard to access from outside:
class BankAccount:
def __init__(self, owner, balance):
self.owner = owner
self.__balance = balance # private — double underscore
account = BankAccount("Ahmad", 10000)
print(account.owner) # Ahmad — public, accessible
print(account.__balance) # AttributeError — private, not accessiblePython does not have true private variables like some other languages. __balance is still technically accessible via account._BankAccount__balance — but this is intentionally ugly. It signals clearly: you are breaking the rules, proceed at your own risk. In practice, nobody does this in real code.
Single underscore — a convention
A single underscore _balance is a softer signal — "this is internal, please do not touch it directly." It has no enforcement — it is purely a convention between developers:
class BankAccount:
def __init__(self, owner, balance):
self._balance = balance # "please don't touch this directly"| Style | Meaning | Enforced? |
|---|---|---|
balance | Public — anyone can access | No |
_balance | Internal — please do not touch | No — convention only |
__balance | Private — name mangled | Partially |
Getters and setters — the manual way
Once data is private, you need controlled ways to read and change it. The traditional approach is getter and setter methods:
class BankAccount:
bank_name = "HBL Bank"
def __init__(self, owner, balance):
self.owner = owner
self.__balance = balance
# getter — read the balance
def get_balance(self):
return self.__balance
# setter — change the balance with validation
def set_balance(self, amount):
if not isinstance(amount, (int, float)):
print("Balance must be a number.")
elif amount < 0:
print("Balance cannot be negative.")
else:
self.__balance = amount
print(f"Balance updated to {self.__balance:,} PKR")
account = BankAccount("Ahmad", 10000)
# read balance
print(account.get_balance()) # 10000
# change balance — goes through validation
account.set_balance(25000) # Balance updated to 25,000 PKR
account.set_balance(-500) # Balance cannot be negative.
account.set_balance("hello") # Balance must be a number.This works — but it is not very Pythonic. You have to remember to call get_balance() and set_balance() every time. Python has a cleaner way.
@property — the Pythonic way
The @property decorator lets you access a method like it is a variable — no parentheses. Combined with a setter, you get clean access with full validation:
class BankAccount:
bank_name = "HBL Bank"
def __init__(self, owner, balance):
self.owner = owner
self.__balance = balance
@property
def balance(self):
"""The account balance — read only access."""
return self.__balance
@balance.setter
def balance(self, amount):
if not isinstance(amount, (int, float)):
raise TypeError("Balance must be a number.")
if amount < 0:
raise ValueError("Balance cannot be negative.")
self.__balance = amountNow you use it like a normal variable — but all validation runs automatically:
account = BankAccount("Ahmad", 10000)
# read — calls the @property getter
print(account.balance) # 10000
# write — calls the @balance.setter
account.balance = 25000
print(account.balance) # 25000
# invalid — raises ValueError
account.balance = -500 # ValueError: Balance cannot be negative.Clean, readable, and safe.
@property is the modern Python standard. Use it instead of manual get_balance() / set_balance() methods. It gives you the clean syntax of direct attribute access with the protection of a method.
Read-only properties
If you define a @property without a setter, the attribute becomes read-only:
class BankAccount:
bank_name = "HBL Bank"
def __init__(self, owner, balance):
self.__owner = owner
self.__balance = balance
self.__account_number = f"HBL-{id(self) % 100000:05d}"
@property
def owner(self):
return self.__owner
@property
def balance(self):
return self.__balance
@property
def account_number(self):
"""Read-only — generated once, never changes."""
return self.__account_number
account = BankAccount("Ahmad", 10000)
print(account.owner) # Ahmad
print(account.balance) # 10000
print(account.account_number) # HBL-12345 (some number)
# trying to change a read-only property raises AttributeError
account.account_number = "HBL-99999" # AttributeError: can't set attribute@property.deleter — controlling deletion
You can also control what happens when someone tries to delete an attribute:
class BankAccount:
def __init__(self, owner, balance):
self.__owner = owner
self.__balance = balance
@property
def balance(self):
return self.__balance
@balance.deleter
def balance(self):
raise AttributeError("Balance cannot be deleted.")
account = BankAccount("Ahmad", 10000)
del account.balance # AttributeError: Balance cannot be deleted.A fully encapsulated BankAccount
Bringing it all together — a properly protected bank account:
class BankAccount:
bank_name = "HBL Bank"
__total_accounts = 0 # private class variable
def __init__(self, owner, opening_balance):
self.__owner = owner
self.__balance = 0
self.__transactions = []
# use the setter for validation on creation too
self.balance = opening_balance
BankAccount.__total_accounts += 1
# --- Properties ---
@property
def owner(self):
return self.__owner
@property
def balance(self):
return self.__balance
@balance.setter
def balance(self, amount):
if not isinstance(amount, (int, float)):
raise TypeError("Balance must be a number.")
if amount < 0:
raise ValueError("Balance cannot be negative.")
self.__balance = amount
@property
def transactions(self):
return list(self.__transactions) # return a copy — not the original
# --- Instance methods ---
def deposit(self, amount):
if not isinstance(amount, (int, float)) or amount <= 0:
print("Deposit amount must be a positive number.")
return
self.__balance += amount
self.__transactions.append(("deposit", amount))
print(f"Deposited {amount:,} PKR → Balance: {self.__balance:,} PKR")
def withdraw(self, amount):
if not isinstance(amount, (int, float)) or amount <= 0:
print("Withdrawal amount must be a positive number.")
return
if amount > self.__balance:
print(f"Insufficient balance. Available: {self.__balance:,} PKR")
return
self.__balance -= amount
self.__transactions.append(("withdrawal", amount))
print(f"Withdrawn {amount:,} PKR → Balance: {self.__balance:,} PKR")
def statement(self):
print(f"\n{'=' * 40}")
print(f" {BankAccount.bank_name} — Account Statement")
print(f"{'=' * 40}")
print(f" Owner: {self.__owner}")
print(f" Balance: {self.__balance:,} PKR")
print(f"{'─' * 40}")
if not self.__transactions:
print(" No transactions yet.")
else:
for t_type, t_amount in self.__transactions:
symbol = "+" if t_type == "deposit" else "-"
print(f" {symbol} {t_amount:,} PKR ({t_type})")
print(f"{'=' * 40}\n")
# --- Class methods ---
@classmethod
def change_bank_name(cls, new_name):
cls.bank_name = new_name
print(f"Bank name changed to: {cls.bank_name}")
@classmethod
def get_total_accounts(cls):
return cls.__total_accounts
# --- Static methods ---
@staticmethod
def validate_amount(amount):
return isinstance(amount, (int, float)) and amount > 0
@staticmethod
def convert_to_usd(pkr_amount, rate=280):
return round(pkr_amount / rate, 2)Using the complete encapsulated account:
account1 = BankAccount("Ahmad", 10000)
account2 = BankAccount("Sara", 25000)
account1.deposit(5000)
account1.deposit(3000)
account1.withdraw(2000)
account1.withdraw(99999) # blocked — insufficient balance
account1.statement()
# reading properties — clean access
print(account1.owner) # Ahmad
print(account1.balance) # 16000
# trying to break the rules
account1.balance = -500 # ValueError: Balance cannot be negative.
# class and static methods still work
BankAccount.change_bank_name("MCB Bank")
print(BankAccount.get_total_accounts()) # 2
print(BankAccount.convert_to_usd(28000)) # 100.0Output:
Deposited 5,000 PKR → Balance: 15,000 PKR
Deposited 3,000 PKR → Balance: 18,000 PKR
Withdrawn 2,000 PKR → Balance: 16,000 PKR
Insufficient balance. Available: 16,000 PKR
========================================
HBL Bank — Account Statement
========================================
Owner: Ahmad
Balance: 16,000 PKR
────────────────────────────────────────
+ 5,000 PKR (deposit)
+ 3,000 PKR (deposit)
- 2,000 PKR (withdrawal)
========================================
Ahmad
16000
Bank name changed to: MCB Bank
2
100.0Why return a copy of transactions?
Notice this line in the transactions property:
return list(self.__transactions) # return a copy — not the originalIf you returned self.__transactions directly, the caller could modify the list and bypass all your protection:
# without copy — dangerous
txns = account.transactions
txns.append(("deposit", 999999999)) # modifies the real list!
# with copy — safe
txns = account.transactions
txns.append(("deposit", 999999999)) # modifies only the copyAlways return copies of mutable internal data.
Summary
| Concept | Example |
|---|---|
| Public attribute | self.owner |
| Private attribute | self.__balance |
| Convention — internal | self._balance |
| Read a private value | @property |
| Write with validation | @property_name.setter |
| Read-only property | @property with no setter |
| Prevent deletion | @property_name.deleter |