Python Encapsulation
Encapsulation is the object-oriented practice of bundling data (attributes) and the methods that operate on that data inside a single unit — a class — while controlling how the outside world is allowed to access or change that data. Instead of letting any part of a program freely reach in and mutate an object’s internal state, encapsulation hides implementation details behind a well-defined interface, so the object itself can enforce the rules about what values are valid. In Python, encapsulation is achieved through naming conventions and language mechanisms like name mangling and properties, rather than compiler-enforced access modifiers such as private or public keywords in Java or C++. That makes it especially important to understand how it really works instead of assuming it behaves like those languages.
Overview: How Encapsulation Works in Python
Every Python object stores its attributes in a dictionary-like namespace (usually accessible via vars(obj) or obj.__dict__). By default, absolutely nothing stops external code from reading or writing any attribute on that object — Python’s design philosophy, often summarized as "we’re all consenting adults here," trusts programmers to respect conventions rather than relying on the interpreter to police access. Because of this, Python encapsulation works on three levels of increasing (but never absolute) restriction:
- Public — a normal name like
self.balance. Freely readable and writable from anywhere. This is the default and should be used for attributes that are genuinely part of the object’s public interface. - Protected (by convention) — a name with a single leading underscore, like
self._balance. This is a signal to other developers meaning "this is an implementation detail; don’t touch it from outside the class or its subclasses," but Python does not enforce this in any way. It is purely a documentation convention, though tools like linters and IDEs will often flag external access to it. - Private (name-mangled) — a name with two leading underscores and at most one trailing underscore, like
self.__balance. Python’s compiler actually rewrites this name at parse time inside the class body, turning every occurrence of__balanceinto_ClassName__balance. This is called name mangling, and it is the closest thing Python has to real private attributes — but it is still just a renaming trick, not true access control, since the mangled name is easy to discover and use directly.
On top of these naming conventions, Python provides the @property decorator, which lets a class expose what looks like a simple public attribute while actually running code (validation, computation, logging) whenever that attribute is read, written, or deleted. This is the idiomatic Python way to enforce invariants on an object’s data without forcing every caller to use verbose get_x()/set_x() methods.
Syntax
class ClassName:
def __init__(self, value):
self.public_attr = value # no leading underscore
self._protected_attr = value # single leading underscore (convention)
self.__private_attr = value # double leading underscore (name-mangled)
@property
def some_value(self):
return self.__private_attr
@some_value.setter
def some_value(self, new_value):
if new_value < 0:
raise ValueError("value must be non-negative")
self.__private_attr = new_value
| Convention | Example | Meaning |
|---|---|---|
| Public | self.name |
Part of the public API; access freely |
| Protected | self._name |
Internal use only, by convention; not enforced |
| Private | self.__name |
Name-mangled to _ClassName__name; discourages accidental access and subclass clashes |
| Dunder | self.__name__ |
Reserved for Python’s own special methods/attributes; not mangled because it has a trailing double underscore too |
Examples
Example 1: Public vs. Protected Attributes
class BankAccount:
def __init__(self, owner: str, balance: float = 0.0):
self.owner = owner # public attribute
self._balance = balance # "protected" by convention
def deposit(self, amount: float) -> None:
if amount <= 0:
raise ValueError("Deposit amount must be positive")
self._balance += amount
def get_balance(self) -> float:
return self._balance
account = BankAccount("Maria", 100.0)
account.deposit(50.0)
print(f"{account.owner}'s balance: {account.get_balance()}")
# Nothing stops direct access, but the underscore signals "internal use only"
account._balance -= 1000 # discouraged, but not blocked
print(f"After risky direct edit: {account.get_balance()}")
Output:
Maria's balance: 150.0
After risky direct edit: -850.0
The single underscore on _balance does nothing to stop account._balance -= 1000 from executing — it runs without error and silently corrupts the account. That’s the point: a single underscore is a courtesy signal between developers, not a lock.
Example 2: Name Mangling with Double Underscores
class Account:
def __init__(self, owner: str, balance: float = 0.0):
self.owner = owner
self.__balance = balance # mangled to _Account__balance
def deposit(self, amount: float) -> None:
if amount <= 0:
raise ValueError("Deposit amount must be positive")
self.__balance += amount
def get_balance(self) -> float:
return self.__balance
acc = Account("Sam", 200.0)
acc.deposit(25.0)
print(acc.get_balance())
try:
print(acc.__balance)
except AttributeError as e:
print(f"AttributeError: {e}")
print(acc._Account__balance)
print(vars(acc))
Output:
225.0
AttributeError: 'Account' object has no attribute '__balance'
225.0
{'owner': 'Sam', '_Account__balance': 225.0}
Even though __balance looks private, it still exists — just under the mangled name _Account__balance, visible in vars(acc). Anyone who knows the mangling rule can still reach it directly. Name mangling mainly protects against accidental name clashes in inheritance hierarchies, not against deliberate access.
Example 3: Enforcing Validation with @property
class Temperature:
def __init__(self, celsius: float):
self._celsius = celsius
@property
def celsius(self) -> float:
return self._celsius
@celsius.setter
def celsius(self, value: float) -> None:
if value < -273.15:
raise ValueError("Temperature below absolute zero is not physically possible")
self._celsius = value
@property
def fahrenheit(self) -> float:
return self._celsius * 9 / 5 + 32
temp = Temperature(25.0)
print(f"{temp.celsius}C = {temp.fahrenheit}F")
temp.celsius = 100.0
print(f"{temp.celsius}C = {temp.fahrenheit}F")
try:
temp.celsius = -300.0
except ValueError as e:
print(f"ValueError: {e}")
Output:
25.0C = 77.0F
100.0C = 212.0F
ValueError: Temperature below absolute zero is not physically possible
temp.celsius = 100.0 looks like plain attribute assignment, but it actually calls the celsius.setter method, which validates the value before storing it in self._celsius. This gives callers a clean, attribute-like interface while the class retains full control over its own invariants — the essence of encapsulation done the Pythonic way.
Under the Hood: What Python Actually Does
Name mangling happens purely as a lexical, compile-time text substitution, not a runtime check based on where the code is called from. When the Python compiler processes the body of a class statement, any identifier that starts with two or more underscores and ends with at most one underscore (like __balance, but not __init__, which has a trailing double underscore) is rewritten to _ClassName__identifier everywhere it appears inside that class body. This happens before the class even runs, so self.__balance = balance inside Account.__init__ is compiled as if you had written self._Account__balance = balance. That’s why the mangled name shows up in vars(acc) and why accessing acc.__balance from outside the class raises AttributeError — that exact name was never actually created.
@property works through a completely different mechanism: the descriptor protocol. Decorating a method with @property wraps it in a property object, which is a descriptor implementing __get__, __set__, and __delete__. Descriptors live on the class, not the instance, and Python’s attribute lookup machinery checks the class for a data descriptor before it ever looks at the instance’s own __dict__. That’s why temp.celsius triggers your getter method instead of returning a plain stored value, and why temp.celsius = -300.0 runs your validation logic instead of just overwriting a dictionary entry.
Common Mistakes
Mistake 1: Assuming a leading underscore enforces privacy. Many newcomers write self._balance and then are surprised that account._balance = -9999 works without any error. A single underscore is a convention read by humans and linters, not by the interpreter. If you need real enforcement of a rule (like "balance can never go negative"), you must use a @property setter that validates the value, as shown in Example 3.
Mistake 2: Not realizing name mangling can silently break inheritance. Because mangling is based on the class where the attribute is written, a subclass that reuses a double-underscore name doesn’t override the parent’s attribute — it creates a completely separate one.
class Base:
def __init__(self):
self.__value = 10 # becomes _Base__value
def show(self):
print(self.__value) # always reads _Base__value
class Child(Base):
def __init__(self):
super().__init__()
self.__value = 20 # becomes _Child__value, a DIFFERENT attribute
def show_child(self):
print(self.__value) # reads _Child__value
c = Child()
c.show()
c.show_child()
Output:
10
20
Both attributes exist side by side inside the same instance because they were mangled against different class names. This surprises many developers, and it’s exactly why name mangling exists: it’s meant to prevent this kind of accidental clash from causing silent bugs when a subclass happens to choose the same attribute name for something unrelated. Use it deliberately, not by default on every attribute.
Best Practices
- Start with plain public attributes. Only introduce a leading underscore, and later a
@property, when you actually need to hide an implementation detail or enforce a rule — don’t add ceremony prematurely. - Use a single leading underscore (
_name) for attributes and helper methods that are internal to the class or module but don’t need name-mangling protection. - Reserve double leading underscores (
__name) for cases where you specifically want to avoid accidental overriding in subclasses, not as a general-purpose "make this private" switch. - Prefer
@propertyand its.setter/.deleterover Java-styleget_x()/set_x()methods — it keeps the calling code reading like simple attribute access while still letting you add validation later without breaking any caller. - Document the intended access level of protected attributes in docstrings, since Python itself won’t enforce or advertise it at runtime.
- If you need to prevent arbitrary new attributes from being added to instances at all, look into
__slots__, a related but distinct mechanism from name mangling. - Never rely on name mangling as a security boundary — it only guards against accidental name collisions, not determined or malicious access.
Practice Exercises
Exercise 1: Write a Person class with a private __age attribute. Expose it through an age property whose setter raises ValueError if the new value is negative. Verify that setting person.age = -5 raises the error while person.age = 30 works.
Exercise 2: Using the Base/Child example from the Common Mistakes section, predict what c.show() and c.show_child() would print if Child.__init__ did not call super().__init__(). Then reason about why.
Exercise 3: Refactor a class that has explicit get_email() and set_email(value) methods (where set_email should reject strings without an @ character) into a class that instead uses a @property named email with the same validation behavior.
Summary
- Encapsulation bundles data and behavior into a class and controls how outside code can access that data.
- Python has no true
privatekeyword; it relies on naming conventions: public,_protected, and__private. - Double-underscore names undergo name mangling at compile time, becoming
_ClassName__name— this mainly guards against accidental clashes in subclasses, not true security. - The
@propertydecorator, backed by the descriptor protocol, is the idiomatic way to add validation or computed behavior while keeping a clean, attribute-like interface. - Because nothing in Python is truly locked down, encapsulation is ultimately a discipline the whole codebase agrees to follow, enforced by conventions, code review, and tools like linters rather than the interpreter itself.
