TL;DR
Python supports encapsulation and good API design, but ordinary Python code rarely gets a hard private boundary. I tested naming conventions, modules, properties, __slots__, custom module behavior, and lexical closures to see where that boundary actually sits.
The pattern was surprisingly consistent: Python gives developers strong tools for designing boundaries, but very few ways to make those boundaries impossible for other Python code to cross deliberately.
Barbara Liskov's recent ACM conversation with Ryan Peterman pushed me to look at this more closely. While discussing Python's module system, she raises a broader concern about enforced encapsulation and what happens when large teams build software without strong information-hiding boundaries.
Before going further, one terminology point matters.
Python absolutely supports encapsulation in the common object-oriented sense of grouping state and behavior behind an interface.
What I'm testing here is the stronger idea of information hiding and access control: whether ordinary Python code can make an implementation detail inaccessible to other Python code.
I'm also keeping the scope deliberately narrow. Native extensions, process isolation, operating-system permissions, and sandboxes are different kinds of boundaries.
The question is simply:
Can I mark some state as internal and have Python itself stop other Python code from deliberately reaching or changing it?
I tried it.
1. _name is a convention
The most familiar Python convention for internal state is a leading underscore.
class Account:
def __init__(self):
self._balance = 1000
def get_balance(self):
return self._balance
The intention is obvious: _balance isn't supposed to be part of the public API.
But nothing prevents this:
account = Account()
account._balance = -500
print(account.get_balance())
Output:
-500
The underscore communicates intent.
It says:
This is internal. Please don't depend on it.
It doesn't mean:
Python won't let you touch it.
Python's own tutorial describes leading-underscore names as non-public implementation details rather than inaccessible members.
2. What about modules and __all__?
Modules look like a natural place for a stronger boundary.
Suppose account.py contains:
__all__ = ["get_balance"]
_balance = 1000
def get_balance():
return _balance
Now:
from account import *
exports get_balance, but not _balance.
That's useful because it makes the intended public API clearer.
But this still works:
import account
print(account._balance)
account._balance = -1000
__all__ helps define what a module exposes as its public interface, especially for wildcard imports. It does not make everything else inaccessible.
In other words:
not public
isn't the same as:
cannot be accessed
That distinction becomes important later when we get to current proposals around module visibility.
3. Double underscores look more promising
Python also gives us double-underscore names:
class Account:
def __init__(self):
self.__balance = 1000
def get_balance(self):
return self.__balance
Now this fails:
account = Account()
print(account.__balance)
Python raises an AttributeError.
At first glance, this looks like a private field.
But Python is doing name mangling.
The attribute becomes something similar to:
_Account__balance
So this works:
print(account._Account__balance)
And so does this:
account._Account__balance = -1000
print(account.get_balance())
Output:
-1000
This isn't an accidental loophole.
Python documents name mangling primarily as a way to avoid accidental name collisions, particularly with subclasses. Deliberate access is still possible.
Useful? Definitely.
Hard privacy? No.
4. Properties protect the interface, not the representation
Instead of exposing the field directly, let's put a proper interface around it.
class Account:
def __init__(self):
self.__balance = 1000
@property
def balance(self):
return self.__balance
@balance.setter
def balance(self, value):
if value < 0:
raise ValueError("balance cannot be negative")
self.__balance = value
Now this is rejected:
account.balance = -500
That's exactly what we want from the public API.
But someone can still deliberately bypass it:
account._Account__balance = -500
For an ordinary object with an instance dictionary, there's another route:
account.__dict__["_Account__balance"] = -500
Properties are excellent for maintaining invariants through an interface.
They don't make the underlying representation unreachable.
5. A quick __slots__ check
What if the instance dictionary is the problem?
class Account:
__slots__ = ("__balance",)
def __init__(self):
self.__balance = 1000
Now account.__dict__ is gone.
But this still works:
account._Account__balance = -1000
So __slots__ closes one route, but privacy isn't what it was designed for. It changes instance layout and restricts which attributes instances can acquire.
The mangled attribute itself is still reachable.
Python can do something more interesting
So far, these are mostly familiar Python features.
The object model goes much deeper.
Python lets us customize attribute behavior through methods such as:
__getattribute__
__getattr__
__setattr__
__delattr__
It also has descriptors, which participate directly in attribute lookup and assignment. Properties themselves use the descriptor protocol.
That raises a better question:
If Python doesn't give us a hard boundary directly, can we build one ourselves?
6. Building a guarded module
Python modules are objects too.
We can create a custom module type that refuses access to an internal attribute:
import types
module = types.ModuleType("guarded")
module.__dict__["_secret"] = "top secret"
class GuardedModule(types.ModuleType):
def __getattribute__(self, name):
if name == "_secret":
raise AttributeError("private")
return super().__getattribute__(name)
def __setattr__(self, name, value):
if name == "_secret":
raise AttributeError("private")
return super().__setattr__(name, value)
module.__class__ = GuardedModule
Now ordinary access is blocked:
module._secret
Result:
AttributeError: private
Assignment is blocked too:
module._secret = "changed"
This feels much closer to an enforced boundary.
But we can deliberately go underneath our own attribute handler and retrieve the module namespace:
namespace = types.ModuleType.__getattribute__(
module,
"__dict__"
)
print(namespace["_secret"])
Output:
top secret
And we can modify it:
namespace["_secret"] = "changed"
Our module controls its normal interface.
It hasn't made the underlying state unreachable to Python code that intentionally works around that interface.
This was the point where the trade-off became clearer to me:
Python gives us enough control to build the wall. It also gives us enough introspection to look behind it.
7. What about lexical closures?
Closures looked like the obvious counterexample.
Unlike the previous examples, a closure doesn't need to keep its state as a normal attribute on some Account object.
def make_account(initial_balance):
balance = initial_balance
def get_balance():
return balance
def deposit(amount):
nonlocal balance
if amount <= 0:
raise ValueError("deposit must be positive")
balance += amount
return get_balance, deposit
Use it:
get_balance, deposit = make_account(1000)
print(get_balance()) # 1000
deposit(500)
print(get_balance()) # 1500
There is no account.balance to overwrite.
There isn't even an account instance whose __dict__ contains the balance.
That looks like a stronger boundary.
But Python exposes a function's closure cells.
Instead of assuming which position contains balance, we can locate it by name:
index = get_balance.__code__.co_freevars.index("balance")
cell = get_balance.__closure__[index]
print(cell.cell_contents)
Output:
1500
Reading it is already interesting.
But modern Python lets us change the cell too:
cell.cell_contents = -500
print(get_balance())
Output:
-500
And the other closure continues working with the modified value:
deposit(100)
print(get_balance())
Output:
-400
No ctypes, C API, native extension, or bytecode manipulation is involved here. This is ordinary Python-level introspection: Python's data model explicitly allows cell_contents to be used to get and set the value stored in a closure cell.
Closures looked like the obvious counterexample.
They weren't.
They keep state away from ordinary attribute access much more effectively than the earlier examples, but deliberate Python-level introspection can still reach and modify that state.
Is that actually a problem?
Not necessarily.
The same openness enables a lot of useful Python behavior.
Testing tools can patch dependencies at runtime. Frameworks can inspect objects dynamically. ORMs can turn class attributes into database mappings. Decorators can transform behavior. Debuggers can inspect live state. Descriptors can implement managed attributes. Metaclasses can participate in class creation. Interactive environments can inspect and modify running objects.
A stricter runtime model could make some of those patterns harder.
So I don't think the useful conclusion is:
Python's encapsulation is broken.
The more interesting conclusion is that Python makes a particular trade-off.
It gives developers strong tools for creating abstractions while leaving much of the underlying runtime accessible to another developer who deliberately wants to inspect it.
Why this matters more in large codebases
Imagine a package contains:
package._internal_state
The author considers it internal.
One team uses it anyway.
Then another team depends on it.
Eventually someone builds tooling around it.
Six months later, the package author wants to change the implementation.
Technically, _internal_state was never public.
Practically, several systems now depend on it.
The underscore said:
Please don't use this.
A hard access boundary would have said:
You cannot build against this interface.
Those approaches create different engineering incentives.
This is where the discussion stops being about Python syntax and becomes a question of software architecture.
Good abstractions let implementations change without requiring every caller to understand their internals.
Convention can encourage that separation.
Access control can enforce it.
Python usually chooses convention.
That is also close to the concern Liskov raises in the original conversation: module boundaries become especially important when many programmers are working on the same system.
Static analysis can make Python stricter
Python teams don't have to rely entirely on convention.
Tools such as Pyright can flag incorrect use of private or protected variables and functions through reportPrivateUsage.
A team can make that part of CI:
private API access
|
v
static analysis
|
v
CI failure
For many projects, that may be enough.
But notice where the restriction comes from.
The Python runtime still permits the operation.
The project has added another layer that says the operation isn't allowed.
That's effective engineering, but it's different from a language-level private access modifier.
Python is still thinking about public and private APIs
There's also a timely language-design angle.
PEP 844, created in August 2026, proposes public() and private() built-ins for module-level APIs.
At first glance, that might sound like Python moving toward traditional access modifiers.
It isn't.
The proposal is about module-level visibility and keeping __all__ synchronized with the names a module declares public.
A module-level class or function can itself be decorated @public or @private, but the proposal does not govern the visibility of attributes or methods inside a class. Function-local names and names in nested scopes are also outside its scope.
Most importantly, the PEP's security section is explicit: these declarations are documentation, not access control.
As of September 2026, PEP 844 remains a draft targeting Python 3.16.
That fits remarkably well with these experiments.
Python clearly cares about communicating the difference between public APIs and implementation details.
What it usually doesn't do is turn that distinction into a hard Python-level access barrier.
So does Python have encapsulation?
It depends on what we mean by the word.
If encapsulation means:
Put state and behavior behind a designed interface.
Then yes.
Python supports that very well.
If it means:
Make implementation details inaccessible to outside Python code through enforced private access control.
Then ordinary Python classes and modules generally don't provide that guarantee.
Python's own tutorial is unusually direct here: private instance variables that can only be accessed from inside an object don't exist in Python, and name mangling is designed mostly to avoid accidental clashes rather than deliberate access.
That's why:
"Python has no encapsulation."
is provocative, but incomplete.
A more precise description is:
Python supports abstraction and encapsulated API design, but usually relies on conventions and tooling rather than hard access restrictions for information hiding.
That's the distinction these experiments kept exposing.
What these mechanisms give us
| Mechanism | Expresses API intent | Controls normal access | Stops deliberate Python-level access |
|---|---|---|---|
_name |
Yes | No | No |
__all__ |
Yes | Controls exports | No |
__name |
Partly | Yes | No |
@property |
Yes | Yes | No |
__slots__ |
Not primarily | Restricts instance layout | No |
| Custom module type | Yes | Yes | Can be bypassed |
| Lexical closures | Implicitly | Yes | No; closure cells can be inspected and changed |
| Static analysis | Yes | Can enforce project rules | Runtime still permits access |
My takeaway
I started with a question about something Python supposedly couldn't do. What surprised me more was how much Python can do around the boundary and how little of that power translates into making anything actually untouchable.
Python gives developers a lot of control over abstraction, but relatively little power to stop another developer from deliberately looking underneath it.
Whether that's a strength or a weakness depends on what you're building.
But it's a much more interesting question than whether Python has a private keyword.
References
- Python Tutorial: Classes - Private Variables and Name Mangling
- Python Tutorial: Modules and
__all__ - Python Language Reference: Data Model
- PEP 8: Style Guide for Python Code
- PEP 844:
public()andprivate()built-ins - Pyright Configuration:
reportPrivateUsage - Barbara Liskov and Ryan Peterman: Full Conversation
- Episode Transcript: "The drawbacks of Python"
Top comments (0)