Encapsulation and Access Modifiers#
Encapsulation bundles data with the methods that operate on it and controls how the outside world interacts with internal state. Python takes a different path from Java or C++: there are no true access modifiers — only conventions, name mangling, descriptors, and @property. Understanding this philosophy is essential for writing idiomatic Python and answering interview questions honestly.
This page expands the encapsulation sections in Introdicion to OOPs and Classes and OOP Basics.
How to use this page
Read alongside @property Decorators. Related: Inheritance and Multiple Inheritance, Dunder or Magic Methods.
At a glance
| Track | Python Advanced → Object Oriented Programming |
| Sections | 13 major topics |
| Outline | Use the right-hand TOC to jump |
Topics: Python's trust-based model · The three naming conventions · When to use each level · @property — the Pythonic access gate · Read-only and write-only patterns · Descriptors — how @property enforces access · __slots__ — restricting attributes · Module-level encapsulation — the underscore prefix · … (+5 more)
- Python's trust-based model
- The three naming conventions
- When to use each level
@property— the Pythonic access gate- Read-only and write-only patterns
- Descriptors — how
@propertyenforces access __slots__— restricting attributes- Module-level encapsulation — the underscore prefix
@dataclassand encapsulation- Encapsulation via composition
__getattr__,__setattr__,__getattribute__— dynamic access- Comparison with other languages (interview talking points) … and 1 more sections
Python's trust-based model#
| Language | Mechanism | Enforced? |
|---|---|---|
| Java | private, protected, public |
Compile-time |
| C++ | access specifiers | Compile-time |
| Python | _single, __double underscore |
Convention only (mostly) |
Python's philosophy (from The Zen of Python): "We are all consenting adults here." Visibility is a social contract, not a compiler barrier — with one partial exception (name mangling).
The three naming conventions#
class Account:
def __init__(self, owner: str, balance: float):
self.owner = owner # public — part of API
self._balance = balance # internal — "please don't touch"
self.__pin = "1234" # mangled — avoids subclass collisions
def deposit(self, amount: float) -> None:
self._validate(amount)
self._balance += amount
def _validate(self, amount: float) -> None:
if amount <= 0:
raise ValueError("amount must be positive")
| Syntax | Meaning | Accessible as | Use when |
|---|---|---|---|
name |
Public API | obj.name |
Stable interface |
_name |
Internal / protected by convention | obj._name still works |
Implementation details |
__name |
Name mangled to _ClassName__name |
Harder accidental access | Avoid subclass attribute clashes |
Name mangling in action#
class Parent:
def __init__(self):
self.__secret = 42
class Child(Parent):
def reveal(self):
# self.__secret would look for _Child__secret — doesn't exist
return self._Parent__secret # 42 — still accessible!
p = Parent()
p._Parent__secret # 42 — mangling is not encryption
Interview trap — __private is not security
Name mangling prevents accidental shadowing in subclasses, not intentional access. Never store secrets assuming __ hides them.
When to use each level#
| Scenario | Choice |
|---|---|
| Field is part of stable public API | self.name |
| Internal helper method | self._helper() |
| Subclass might define same name | self.__internal |
| Validation on read/write | @property over _field |
| Truly hide from subclasses | Rare — reconsider design |
Interview default: public attributes unless validation or invariants demand @property.
@property — the Pythonic access gate#
class Celsius:
def __init__(self, temp: float):
self.temp = temp # routes through setter
@property
def temp(self) -> float:
return self._temp
@temp.setter
def temp(self, value: float) -> None:
if value < -273.15:
raise ValueError("below absolute zero")
self._temp = value
Properties provide controlled access without changing call syntax:
Full details: @property Decorators.
Read-only and write-only patterns#
Read-only via property (no setter)#
class Circle:
def __init__(self, radius: float):
self.radius = radius
@property
def radius(self) -> float:
return self._radius
@radius.setter
def radius(self, value: float) -> None:
if value <= 0:
raise ValueError("radius must be positive")
self._radius = value
@property
def area(self) -> float:
return 3.14159 * self._radius ** 2
Returning defensive copies#
class Team:
def __init__(self):
self._members: list[str] = []
@property
def members(self) -> list[str]:
return self._members.copy() # caller can't mutate internal list
def add(self, name: str) -> None:
self._members.append(name)
Without .copy(), team.members.append("intruder") would bypass encapsulation if you returned the internal list directly.
Descriptors — how @property enforces access#
Descriptors are objects with __get__, __set__, or __delete__:
class PositiveNumber:
def __set_name__(self, owner, name):
self.name = f"_{name}"
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.name)
def __set__(self, obj, value):
if value <= 0:
raise ValueError("must be positive")
setattr(obj, self.name, value)
class Product:
price = PositiveNumber()
def __init__(self, price: float):
self.price = price
Product(9.99).price = -1 # ValueError
This is advanced — know it exists; use @property in interviews unless asked.
__slots__ — restricting attributes#
class Point:
__slots__ = ("x", "y")
def __init__(self, x: float, y: float):
self.x = x
self.y = y
p = Point(1, 2)
# p.z = 3 # AttributeError — no __dict__ for arbitrary attrs
| Benefit | Trade-off |
|---|---|
| Lower memory per instance | Fixed attribute set |
| Slightly faster access | Subclass __slots__ must include parent's slots |
| Prevents accidental new attrs | Can't add dynamic attributes |
Module-level encapsulation — the underscore prefix#
# mymodule.py
_public_api = "use me"
_internal_helper = "don't import me"
def public_function(): ...
def _private_helper(): ...
| Import style | Gets |
|---|---|
from mymodule import * |
Only names not starting with _ (unless __all__ defined) |
import mymodule |
Everything — convention still applies |
Define __all__ for explicit public API:
@dataclass and encapsulation#
from dataclasses import dataclass, field
@dataclass
class Config:
host: str
port: int = 8080
_token: str = field(default="", repr=False) # hidden from repr
| Option | Effect |
|---|---|
repr=False |
Exclude from __repr__ |
init=False |
Not in generated __init__ |
frozen=True |
Immutable — no reassignment |
For sensitive fields, repr=False helps but doesn't prevent access — still convention-based.
Encapsulation via composition#
Often better than access modifiers — hide implementation behind an interface:
class Engine:
def __init__(self):
self._rpm = 0
def accelerate(self) -> None:
self._rpm += 100
class Car:
def __init__(self):
self._engine = Engine() # has-a — engine internals hidden
def drive(self) -> None:
self._engine.accelerate()
See Inheritance and Multiple Inheritance for composition vs inheritance.
__getattr__, __setattr__, __getattribute__ — dynamic access#
class DynamicConfig:
def __init__(self, **kwargs):
super().__setattr__("_data", kwargs)
def __getattr__(self, name: str):
try:
return self._data[name]
except KeyError:
raise AttributeError(name) from None
def __setattr__(self, name: str, value):
if name == "_data":
super().__setattr__(name, value)
else:
self._data[name] = value
Use super().__setattr__ inside __setattr__ to avoid infinite recursion.
Comparison with other languages (interview talking points)#
| Question | Python answer |
|---|---|
"Is _x private?" |
No — convention; still accessible |
| "Can I make truly private attrs?" | No — use OS permissions / encryption for secrets |
| "Why no modifiers?" | Reduces boilerplate; trust + tests + code review |
| "How do teams enforce?" | Linters (_ prefix checks), type checkers, API docs |
"When use @property?" |
Validation, computed fields, API stability |
Practical patterns#
Lazy initialization of expensive resource#
class DataLoader:
def __init__(self, path: str):
self._path = path
self._cache: list | None = None
@property
def data(self) -> list:
if self._cache is None:
self._cache = self._load()
return self._cache
def _load(self) -> list:
with open(self._path) as f:
return f.readlines()
Validated setter with public getter semantics#
class Percentage:
def __init__(self, value: float):
self.value = value
@property
def value(self) -> float:
return self._value
@value.setter
def value(self, v: float) -> None:
if not 0 <= v <= 100:
raise ValueError("percentage must be 0-100")
self._value = v
Interview traps (quick reference)#
| Trap | What goes wrong | Safe approach |
|---|---|---|
Assuming __x is secure |
Data still readable via mangled name | Never store secrets in plain attrs |
| Returning internal mutable | External code mutates state | Return copy or immutable view |
@property on everything |
Verbose, un-Pythonic | Public attrs when no invariant |
_ prefix on "private" methods in tests |
Tests break on refactor | Test public behavior |
__getattribute__ override |
Infinite recursion | Delegate to super() carefully |
Ignoring __slots__ inheritance |
Subclass attrs silently break | Include parent slots in child |
Mental model checklist#
- What is the practical difference between
_nameand__name? - Why does Python avoid true private fields?
- When should you use
@propertyinstead of a public attribute? - How does name mangling help with subclass attribute collisions?
- Why return
.copy()from a property that exposes a list?
What's next#
| Topic | Page |
|---|---|
| Property deep dive | @property Decorators |
| Attribute hooks | Dunder or Magic Methods |
| Composition patterns | Inheritance and Multiple Inheritance |
| Intermediate OOP | Classes and OOP Basics |