Encapsulation — Data Hiding Done Right
Introduction
In the previous two tutorials, we learned how to define classes, create objects, and initialize them with constructors. We even added validation inside constructors to reject bad data at creation time.
But here's a problem: what happens after the object is created?
Consider our BankAccount class. The constructor validates that the balance isn't negative. But nothing stops someone from writing account.balance = -5000 afterward, completely bypassing that validation.
account = BankAccount("Alice", 1000.0) # Constructor says: valid ✓
account.balance = -5000 # Direct access says: sure, whatever!
This is like a bank that checks your ID at the door but then leaves the vault wide open. Encapsulation solves this. It's the practice of:
- Hiding internal data — making attributes private so they can't be accessed directly
- Providing controlled access — exposing methods (getters/setters) that validate every change
Encapsulation is not just a theoretical concept. It's the most practical tool you have for writing code that doesn't break when teammates modify it — a concern you'll face in every real-world project.
The Problem Without Encapsulation
Let's see what goes wrong when data is left exposed:
# BAD: No encapsulation — everything is public
class BankAccountUnsafe:
def __init__(self, owner: str, balance: float):
self.owner = owner
self.balance = balance
def withdraw(self, amount: float) -> None:
if amount > self.balance:
print("Insufficient funds!")
return
self.balance -= amount
account = BankAccountUnsafe("Alice", 1000.0)
# Anyone can bypass the withdraw method and break the rules:
account.balance = -99999 # No validation!
account.owner = "" # Empty owner? Sure!
account.balance = "not money" # A string? Why not!
print(account.balance) # 'not money' — disaster
# The withdraw method has validation, but it's useless
# because anyone can just modify balance directly.The problem is clear: public attributes can be changed to anything, at any time, by anyone, bypassing all business rules. Encapsulation is the fix.

Access Modifiers — Controlling Visibility
Both Python and Java offer ways to control who can access an attribute or method. Here's the comparison:
| Modifier | Python Convention | Java Keyword | Who Can Access |
|---|---|---|---|
| Public | self.name (no prefix) | public | Anyone, from anywhere |
| Protected | self._name (single _) | protected | Class itself + subclasses |
| Private | self.__name (double __) | private | Class itself only |
Key difference: Java enforces access modifiers at the compiler level — accessing a private field from outside the class is a compile error. Python uses naming conventions — the double underscore triggers name mangling (the attribute is renamed internally), but determined code can still access it. Python trusts developers to follow conventions.
The principle is the same in both languages: mark internal data as private, and expose only what's necessary.
Encapsulation in Practice — The Safe Bank Account
Let's rebuild BankAccount with proper encapsulation. The core idea: all attributes are private, and every change goes through a method that validates the operation.
class BankAccount:
"""A properly encapsulated bank account.
All attributes are private (double underscore).
All access goes through methods with validation.
"""
def __init__(self, owner: str, balance: float = 0.0):
if not owner.strip():
raise ValueError("Owner name cannot be empty")
if balance < 0:
raise ValueError("Initial balance cannot be negative")
self.__owner = owner.strip() # Private!
self.__balance = balance # Private!
self.__is_frozen = False # Private!
# --- Getters: read-only access to private data ---
@property
def owner(self) -> str:
"""Read-only access to owner name."""
return self.__owner
@property
def balance(self) -> float:
"""Read-only access to balance."""
return self.__balance
@property
def is_frozen(self) -> bool:
"""Read-only access to frozen status."""
return self.__is_frozen
# --- Controlled operations: the ONLY way to change state ---
def deposit(self, amount: float) -> None:
"""Add money to the account with validation."""
if self.__is_frozen:
raise RuntimeError("Cannot deposit into a frozen account")
if amount <= 0:
raise ValueError("Deposit amount must be positive")
self.__balance += amount
print(f"Deposited ${amount:.2f}. New balance: ${self.__balance:.2f}")
def withdraw(self, amount: float) -> None:
"""Remove money from the account with validation."""
if self.__is_frozen:
raise RuntimeError("Cannot withdraw from a frozen account")
if amount <= 0:
raise ValueError("Withdrawal amount must be positive")
if amount > self.__balance:
raise ValueError(
f"Insufficient funds: ${amount:.2f} > ${self.__balance:.2f}"
)
self.__balance -= amount
print(f"Withdrew ${amount:.2f}. New balance: ${self.__balance:.2f}")
def freeze(self) -> None:
"""Freeze the account — no deposits or withdrawals allowed."""
self.__is_frozen = True
print(f"Account for {self.__owner} is now FROZEN.")
# --- Usage ---
account = BankAccount("Alice", 1000.0)
# Read data through properties (getters)
print(f"Owner: {account.owner}") # Alice
print(f"Balance: ${account.balance}") # $1000.0
# Modify through controlled methods
account.deposit(500.0) # Deposited $500.00. New balance: $1500.00
account.withdraw(200.0) # Withdrew $200.00. New balance: $1300.00
# Try to break the rules — all blocked:
try:
account.balance = -5000 # AttributeError: property has no setter
except AttributeError as e:
print(f"Blocked: {e}")
try:
account.withdraw(-100) # ValueError: must be positive
except ValueError as e:
print(f"Blocked: {e}")
try:
account.withdraw(99999) # ValueError: insufficient funds
except ValueError as e:
print(f"Blocked: {e}")
# Freeze the account
account.freeze()
try:
account.deposit(100) # RuntimeError: cannot deposit into frozen
except RuntimeError as e:
print(f"Blocked: {e}")public class BankAccount {
// All fields are private — no direct access from outside
private String owner;
private double balance;
private boolean isFrozen;
public BankAccount(String owner, double balance) {
if (owner == null || owner.trim().isEmpty()) {
throw new IllegalArgumentException("Owner name cannot be empty");
}
if (balance < 0) {
throw new IllegalArgumentException("Initial balance cannot be negative");
}
this.owner = owner.trim();
this.balance = balance;
this.isFrozen = false;
}
// --- Getters: read-only access ---
public String getOwner() {
return owner;
}
public double getBalance() {
return balance;
}
public boolean isFrozen() {
return isFrozen;
}
// --- Controlled operations ---
public void deposit(double amount) {
if (isFrozen) {
throw new RuntimeException("Cannot deposit into a frozen account");
}
if (amount <= 0) {
throw new IllegalArgumentException("Deposit amount must be positive");
}
balance += amount;
System.out.printf("Deposited $%.2f. New balance: $%.2f%n", amount, balance);
}
public void withdraw(double amount) {
if (isFrozen) {
throw new RuntimeException("Cannot withdraw from a frozen account");
}
if (amount <= 0) {
throw new IllegalArgumentException("Withdrawal amount must be positive");
}
if (amount > balance) {
throw new IllegalArgumentException(
String.format("Insufficient funds: $%.2f > $%.2f", amount, balance));
}
balance -= amount;
System.out.printf("Withdrew $%.2f. New balance: $%.2f%n", amount, balance);
}
public void freeze() {
isFrozen = true;
System.out.println("Account for " + owner + " is now FROZEN.");
}
// Note: No setBalance(), no setOwner() — these can ONLY
// be changed through deposit() and withdraw().
public static void main(String[] args) {
BankAccount account = new BankAccount("Alice", 1000.0);
System.out.println("Owner: " + account.getOwner());
System.out.printf("Balance: $%.2f%n", account.getBalance());
account.deposit(500.0);
account.withdraw(200.0);
// account.balance = -5000; // COMPILE ERROR: balance is private
try {
account.withdraw(-100);
} catch (IllegalArgumentException e) {
System.out.println("Blocked: " + e.getMessage());
}
account.freeze();
try {
account.deposit(100);
} catch (RuntimeException e) {
System.out.println("Blocked: " + e.getMessage());
}
}
}Notice the design pattern:
- No setters for
ownerorbalance— there's nosetBalance()method. The balance can only change throughdeposit()andwithdraw(), which enforce business rules. - Getters are read-only — you can check the balance, but you can't change it directly.
- Business logic lives inside the class — the rules about frozen accounts, positive amounts, and insufficient funds are enforced by the object itself, not by the code that uses it.
This is the essence of encapsulation: the object protects its own integrity.
Visualization

When to Use Getters and Setters
A common mistake is to blindly generate getters and setters for every field — this defeats the purpose of encapsulation entirely. Here's the guideline:
| Scenario | Getter? | Setter? | Why |
|---|---|---|---|
| Identity fields (owner, id) | ✅ Yes | ❌ No | These shouldn't change after creation |
| Computed values (balance) | ✅ Yes | ❌ No | Changed only through business operations |
| Display info (name, email) | ✅ Yes | ✅ With validation | Allow updates, but validate |
| Internal state (isFrozen) | ✅ Yes | ❌ No | Changed only through specific methods (freeze()) |
| Implementation detail (cache, internal counter) | ❌ No | ❌ No | Nobody outside needs to know |
The golden rule: if you provide a setter, it must validate. A setter that blindly assigns this.x = x with no checks is just a public field with extra steps.
Common Mistakes
Mistake 1: Getter + Setter for Everything
❌ Wrong: Auto-generating getX() and setX() for every field. This is just public fields with extra boilerplate.
✅ Right: Only expose what external code genuinely needs. Prefer operation methods (deposit, withdraw) over raw setters (setBalance).
Mistake 2: Returning Mutable References
❌ Wrong:
@property
def transactions(self):
return self.__transactions # Returns the actual list!
The caller can call .append() or .clear() on the returned list, modifying the private data.
✅ Right: Return a copy:
@property
def transactions(self):
return list(self.__transactions) # Returns a defensive copy
Mistake 3: Confusing Encapsulation with Hiding Logic
Encapsulation is not about making everything private and untouchable. It's about providing a clean, controlled interface while keeping implementation details flexible. If your class is so locked down that it's impossible to use, you've gone too far.
Key Takeaways
- Encapsulation = private data + controlled access methods. The object protects its own integrity.
- Access modifiers control visibility: Python uses
__(name mangling), Java uses theprivatekeyword (compiler-enforced) - Getters provide read-only access. Setters should validate — never blindly assign
- Don't expose everything. Only create getters/setters for what external code actually needs. Prefer business operation methods over raw setters
- Return defensive copies of mutable attributes (lists, maps) to prevent external modification
- Encapsulation works hand-in-hand with constructors: constructors validate at birth, encapsulation validates for the lifetime of the object
- In the next tutorial, we'll learn abstraction — a complementary concept that hides complexity by showing only what the user of a class needs to see