What encapsulation actually is
Here is a bank account. Notice what is missing:
1class BankAccount:2 def __init__(self, owner, balance=0):3 self.owner = owner4 self.balance = balance # fully public56account = BankAccount("Alice", 100)78# Anyone can do this9account.balance = -999999 # no validation, no history, just chaos
No error. The balance is a plain attribute. Any code anywhere can reach in and set it to anything. That line bypasses all validation. There is nothing to bypass, because there is no validation.
Now add a requirement: log every deposit. Enforce a minimum balance. Round to two decimal places. Every piece of code that touches account.balance directly becomes a place you have to find and update. You cannot add the logic in one spot because the logic has no spot to live.
That is the problem encapsulation solves. Not security. Not privacy in the Java sense. The ability to change how something works inside without breaking the code that uses it from outside.
Encapsulation is not a lock. It is a seam. You control what goes through it, so you can change what is behind it without touching anything else.
Private by convention, not by lock
Java and C++ have private keywords that the compiler enforces. Python does not. In Python, privacy is a convention communicated through naming.
Single underscore: "please don't touch this"
A name starting with a single underscore is a signal to other developers: this is an internal detail. You can still access it, Python will not stop you, but you are on your own if you do and the behavior changes in a future version.
1class BankAccount:2 def __init__(self, owner, balance=0):3 self.owner = owner4 self._balance = balance # internal, not part of the public API56 def deposit(self, amount):7 if amount <= 0:8 raise ValueError("deposit must be positive")9 self._balance += amount1011 def get_balance(self):12 return self._balance1314account = BankAccount("Alice", 100)15account.deposit(50)16print(account.get_balance()) # 1501718# You can still access _balance, but you're bypassing validation19account._balance = -999 # works, but you've broken the contract
Double underscore: name mangling
A name starting with two underscores (and not ending with two underscores) triggers name mangling. Python renames the attribute from __balance to _ClassName__balance under the hood. This makes it harder to access accidentally and protects it from being overridden by a subclass using the same name.
1class BankAccount:2 def __init__(self, owner, balance=0):3 self.owner = owner4 self.__balance = balance # name mangled to _BankAccount__balance56 def deposit(self, amount):7 self.__balance += amount89 def get_balance(self):10 return self.__balance1112account = BankAccount("Alice", 100)13account.deposit(50)14print(account.get_balance()) # 1501516# Trying to access directly fails17try:18 print(account.__balance)19except AttributeError as e:20 print(e)2122# The real name after mangling23print(account._BankAccount__balance) # 150 -- it's still there, just renamed
In practice, most Python code uses a single underscore. The double underscore is reserved for cases where you genuinely need to protect a name from subclass collisions. Do not use it as a stronger version of private access control. That is not what it is for.
@property: a controlled public interface
Here is a problem: you ship a class where account.balance is a plain attribute. Thousands of lines of code read it that way. Later you need to add validation when balance is set. If you change it to get_balance(), you break all that code.
The @property decorator solves this. It lets you expose an attribute-style interface (account.balance) while controlling exactly what happens when that name is read or written.
1class BankAccount:2 def __init__(self, owner, balance=0):3 self.owner = owner4 self._balance = balance56 @property7 def balance(self):8 return self._balance910 @balance.setter11 def balance(self, amount):12 if amount < 0:13 raise ValueError("balance cannot be negative")14 self._balance = amount1516account = BankAccount("Alice", 100)1718# Reads like a plain attribute19print(account.balance) # 1002021# Writes go through the setter22account.balance = 20023print(account.balance) # 2002425# Validation kicks in26account.balance = -50 # raises ValueError
All the code that reads account.balance still works unchanged. But now you control what happens. The internal storage (_balance) is separate from the public name.
Computed properties
Properties do not have to read from a stored attribute. You can compute a value on the fly and expose it as if it were an attribute:
1class Circle:2 def __init__(self, radius):3 self.radius = radius45 @property6 def area(self):7 import math8 return math.pi * self.radius ** 2910 @property11 def diameter(self):12 return self.radius * 21314c = Circle(5)15print(c.area) # 78.53... computed, not stored16print(c.diameter) # 10 computed, not stored1718c.radius = 1019print(c.area) # 314.15... automatically correct
The caller has no idea whether area is stored or computed. They just read it. That is the point of encapsulation: the implementation can change without breaking the interface.