Skip to main content

Encapsulation in Python

OOP Design

Encapsulation

Encapsulation isn't about private keywords—it's about clear boundaries. Use naming conventions, properties, and helper classes to build trustable APIs.

Privacy by convention

Python has no private keyword. A single leading underscore is the whole mechanism: _balance tells other developers "internal — don't rely on this," and tooling cooperates (from module import * skips it, IDEs de-emphasize it). Nothing stops access; the interpreter doesn't care:

class Account:
def __init__(self):
self._balance = 0.0

acct = Account()
print(acct._balance) # -> 0.0 (allowed — the underscore is a request, not a wall)

That is deliberate. Python trusts callers to respect the boundary and keeps the escape hatch open for debugging and testing.

Name mangling: the double underscore

Two leading underscores trigger name mangling: inside the class body, __pin is rewritten to _ClassName__pin. The attribute still exists under the mangled name — this is renaming, not security:

class Account:
def __init__(self):
self.__pin = '1234'

acct = Account()
acct.__pin
# AttributeError: 'Account' object has no attribute '__pin'

print(acct._Account__pin) # -> 1234
print(acct.__dict__) # -> {'_Account__pin': '1234'}

Mangling's actual purpose is narrow: preventing accidental clashes when a subclass reuses the same attribute name, since each class mangles to its own prefix. For "this is internal," a single underscore is the norm — reach for __ rarely.

Properties

A property exposes attribute syntax while running code on access. The classic use is a read-only view over internal state:

class Account:
def __init__(self):
self._balance = 0.0

@property
def balance(self) -> float:
return self._balance

def deposit(self, amount: float) -> None:
if amount <= 0:
raise ValueError("Deposit must be positive")
self._balance += amount

acct = Account()
acct.deposit(50.0)
print(acct.balance) # -> 50.0
acct.balance = 100.0
# AttributeError: can't set attribute 'balance'

With no setter defined, assignment fails — callers can read the balance but only change it through deposit, where validation lives.

Setters and deleters

Add a setter to allow controlled writes:

class Account:
def __init__(self, balance: float = 0.0):
self._balance = balance

@property
def balance(self) -> float:
return self._balance

@balance.setter
def balance(self, value: float) -> None:
if value < 0:
raise ValueError("Balance cannot be negative")
self._balance = value

acct = Account(100.0)
acct.balance = 250.0
print(acct.balance) # -> 250.0
acct.balance = -50.0
# ValueError: Balance cannot be negative

A deleter handles del obj.attr — occasionally useful for resetting to a computed-later state or invalidating a cache:

class Profile:
def __init__(self, name: str):
self._name = name

@property
def name(self) -> str:
return self._name

@name.deleter
def name(self) -> None:
self._name = '(anonymous)'

p = Profile('Ada')
del p.name
print(p.name) # -> (anonymous)

Start plain, upgrade later

In many languages you write getters and setters up front, because switching a public field to a method breaks every caller. Python removes that pressure: attribute access and property access look identical at the call site, so you can start with a plain attribute and add a property only when a rule appears:

class Temperature:
def __init__(self, celsius: float):
self.celsius = celsius # plain attribute — no ceremony

Later, validation becomes necessary. Same interface, zero caller changes:

class Temperature:
def __init__(self, celsius: float):
self.celsius = celsius # goes through the setter below

@property
def celsius(self) -> float:
return self._celsius

@celsius.setter
def celsius(self, value: float) -> None:
if value < -273.15:
raise ValueError("below absolute zero")
self._celsius = value

t = Temperature(21.5)
print(t.celsius) # -> 21.5
Temperature(-300)
# ValueError: below absolute zero

Note that even __init__ assigns through the setter, so construction is validated too. This is why wrapping every attribute in getters/setters "just in case" is unpythonic: it costs readability now for flexibility Python gives you for free.

Encapsulation also thrives beyond single classes: splitting responsibilities into small collaborating objects hides more implementation detail than any underscore. See composition-friendly design with polymorphism.

Frequently Asked Questions

Does Python have true private attributes?

No. A single underscore is a convention, and double-underscore name mangling only renames the attribute to _ClassName__attr — anyone who knows the scheme can still reach it. Python encapsulation is a contract between developers, not an access-control mechanism enforced by the interpreter.

When should I use _name vs __name?

Default to a single underscore for anything internal. Use double underscores only when a base class attribute must not collide with same-named attributes in subclasses — mangling gives each class its own _ClassName prefix. Using __ merely to "be more private" makes debugging and testing harder for no real protection.

class Base:
def __init__(self):
self.__token = 'base'

class Sub(Base):
def __init__(self):
super().__init__()
self.__token = 'sub'

print(Sub().__dict__)
# -> {'_Base__token': 'base', '_Sub__token': 'sub'}

When should I use @property instead of a plain attribute?

Only when access needs behavior: validation on write, a computed or derived value, a read-only view, or logging. Otherwise use a plain attribute — you can convert it to a property later without breaking callers, so there is nothing to gain by adding one preemptively. Keep property bodies cheap; callers expect attribute access to be fast and side-effect free.

Next up in your learning path