Python Encapsulation
Learn Python encapsulation: public, protected, and private members, name mangling, and clean getters/setters with @property.
Encapsulation is one of the four pillars of object-oriented programming. It means bundling an object's data (attributes) and the methods that operate on that data into a single unit, while controlling which parts of the object the outside world can access or modify.
Done well, encapsulation keeps an object's internal state consistent, hides implementation details so you can change them later without breaking callers, and makes your classes safer to use.
This chapter covers:
- What encapsulation is and why it matters
- Public, protected, and private members — and the naming conventions Python uses
- Name mangling — how
__double_underscoreattributes really work - Getters and setters with
@property - A real-world example tying it all together
Before reading, make sure you are comfortable with Python classes and objects. For access control via computed attributes, see the closely related chapter on @property.
Why Encapsulation Matters
Consider a bank account. Internally it tracks a balance. If that balance were a plain attribute anyone could set, nothing stops a bug (or a bad actor) from doing:
account.balance = -9999999Encapsulation solves this by hiding the balance behind a controlled interface. Code outside the class can only deposit or withdraw through methods that enforce the business rules. The internal storage is an implementation detail — callers never touch it directly.
The three benefits this gives you are:
- Data integrity — validation logic in one place, enforced every time.
- Flexibility — you can change the internal representation (e.g., store balances in cents instead of dollars) without touching any calling code.
- Reduced coupling — callers depend only on the public interface, not on how the class works internally.
Access Levels: Public, Protected, and Private
Python does not have access modifiers like private or public keywords. Instead, it uses a naming convention to signal intent:
| Prefix | Example | Access level | Meaning |
|---|---|---|---|
| No prefix | balance | Public | Intended for use by anyone |
Single underscore _ | _balance | Protected | For internal use and subclasses; usable externally but discouraged |
Double underscore __ | __pin | Private | For this class only; Python actively renames it to prevent easy access |
These are conventions and mechanisms, not hard rules enforced by a compiler. Python trusts developers to respect the signal.
Public members
Public attributes and methods form the class's official interface — the part you intend callers to use:
class BankAccount:
account_type = 'savings' # public class attribute
def __init__(self, owner, balance):
self.owner = owner # public instance attribute
def deposit(self, amount):
pass # public methodNo special naming is needed. Any code can read or write a public member freely.
Protected members (single underscore _)
A single leading underscore is a signal that says "this is an internal detail — please don't rely on it from outside the class." Python does not enforce this; it is purely a convention:
class BankAccount:
def __init__(self, owner, balance):
self.owner = owner
self._balance = balance # protected — internal, but subclasses may need it
def _validate_amount(self, amount): # protected helper
return isinstance(amount, (int, float)) and amount > 0_balance is still accessible as account._balance from outside, but the underscore warns other developers (and linters) that they are breaking the intended contract.
A common use: a base class stores data in a _ attribute so subclasses can read it, while keeping it hidden from unrelated code.
Private members (double underscore __)
A double leading underscore triggers name mangling — Python renames the attribute internally to _ClassName__attribute. This makes accidental access from outside much harder:
class BankAccount:
def __init__(self, owner, balance):
self.owner = owner
self._balance = balance
self.__pin = 1234 # private — not meant to be touched at all
def verify_pin(self, pin):
return pin == self.__pinFrom outside the class:
acc = BankAccount('Alice', 1000)
print(acc.owner) # Alice — public, fine
print(acc._balance) # 1000 — protected, works but frowned upon
print(acc.__pin) # AttributeError: 'BankAccount' object has no attribute '__pin'The attribute still exists, but under a different name. See the next section for how to find it.
Name Mangling
When Python sees self.__name inside a class definition, it internally rewrites it to self._ClassName__name. This is name mangling. The purpose is to avoid accidental clashes in subclasses — not to provide true security.
class Counter:
def __init__(self):
self.__count = 0
def increment(self):
self.__count += 1
def value(self):
return self.__count
c = Counter()
c.increment()
c.increment()
print(c.value()) # 2
# Direct access fails:
# print(c.__count) # AttributeError
# But mangled name still works if you know it:
print(c._Counter__count) # 2You can inspect all attributes with vars() or dir() to discover the mangled name:
print(list(vars(c)))
# ['_Counter__count']Name mangling and inheritance
Name mangling is especially useful in inheritance. Without it, a subclass could accidentally overwrite a private attribute of its parent by using the same name. With mangling, each class gets its own namespace:
class Base:
def __init__(self):
self.__secret = 'base'
def reveal(self):
return self.__secret # accesses _Base__secret
class Child(Base):
def __init__(self):
super().__init__()
self.__secret = 'child' # stored as _Child__secret, not the same thing
def reveal_child(self):
return self.__secret # accesses _Child__secret
c = Child()
print(c.reveal()) # base — Base.reveal() reads _Base__secret
print(c.reveal_child()) # child — Child.reveal_child() reads _Child__secretBoth attributes coexist without collision, which would not be possible without name mangling.
Getters and Setters with @property
In many languages you write explicit get_x() and set_x() methods. Python provides a cleaner approach: the @property decorator lets you expose a method as if it were a plain attribute, so the calling code stays readable while you keep full control over reading and writing.
Basic getter
class Temperature:
def __init__(self, celsius):
self._celsius = celsius
@property
def celsius(self):
return self._celsiusCallers read t.celsius, not t.celsius(). The @property makes the method call invisible:
t = Temperature(25)
print(t.celsius) # 25 — no parentheses neededAdding a setter with validation
Pair @property with a .setter to validate values before storing them:
class Temperature:
def __init__(self, celsius):
self._celsius = celsius
@property
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError('Temperature below absolute zero')
self._celsius = value
@property
def fahrenheit(self):
return self._celsius * 9 / 5 + 32t = Temperature(25)
print(t.celsius) # 25
print(t.fahrenheit) # 77.0
t.celsius = 100
print(t.fahrenheit) # 212.0
t.celsius = -300 # ValueError: Temperature below absolute zerofahrenheit is a read-only computed property — no setter is defined, so Python raises an AttributeError if you try to assign to it.
Why prefer @property over plain getters/setters?
You can start with a plain public attribute and upgrade it to a property later without changing any calling code:
# v1 — plain attribute
class Circle:
def __init__(self, radius):
self.radius = radius
# v2 — property with validation, same public interface
class Circle:
def __init__(self, radius):
self.radius = radius # still works from the caller's point of view
@property
def radius(self):
return self._radius
@radius.setter
def radius(self, value):
if value < 0:
raise ValueError('Radius cannot be negative')
self._radius = valueCallers that wrote c.radius = 5 continue to work unchanged. Only the behaviour changes — you now validate the value.
For a full reference on @property including deleters, see Python @property.
A Complete Example: User Account
The following example shows all three access levels working together in a realistic class:
class UserAccount:
def __init__(self, username, password):
self.username = username # public
self._login_attempts = 0 # protected — subclasses may need this
self.__password_hash = self.__hash(password) # private
def __hash(self, password):
"""Private helper — implementation detail, may change."""
return hash(password)
def check_password(self, password):
"""Public method — part of the official interface."""
return self.__hash(password) == self.__password_hash
def login(self, password):
if self._login_attempts >= 3:
return 'Account locked'
if self.check_password(password):
self._login_attempts = 0
return 'Login successful'
self._login_attempts += 1
return f'Wrong password ({self._login_attempts}/3)'
user = UserAccount('alice', 'secret123')
print(user.login('bad')) # Wrong password (1/3)
print(user.login('bad')) # Wrong password (2/3)
print(user.login('bad')) # Wrong password (3/3)
print(user.login('secret123')) # Account lockedNotice:
usernameis public — it is fine for anyone to read._login_attemptsis protected — aThrottledAccountsubclass could read it to implement smarter logic.__password_hashand__hash()are private — the password storage strategy is purely internal. Callers have no reason to see it, and if you later switch to bcrypt you only change those two things.
Encapsulation vs. Other OOP Pillars
Encapsulation is one of four OOP principles:
| Principle | One-line definition |
|---|---|
| Encapsulation | Bundle data + methods; hide internal details |
| Inheritance | Let a class reuse and extend another class |
| Polymorphism | Let different types respond to the same method call |
| Abstraction | Expose a simplified interface; hide complexity |
See Python inheritance, Python polymorphism, and Python abstract classes for the other pillars.
Quick Reference
| Convention | What it signals | Enforced by Python? |
|---|---|---|
name | Public — use freely | No (always accessible) |
_name | Protected — internal use | No (accessible but conventionally off-limits) |
__name | Private — this class only | Partially — name is mangled to _ClassName__name |
@property | Controlled read access | Yes — getter/setter/deleter hooks |
@name.setter | Controlled write access with validation | Yes |