DEV Community

Karan Verma
Karan Verma

Posted on

How Private Is Python? Testing Its Encapsulation Boundaries

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
Enter fullscreen mode Exit fullscreen mode

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())
Enter fullscreen mode Exit fullscreen mode

Output:

-500
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Now:

from account import *
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

__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
Enter fullscreen mode Exit fullscreen mode

isn't the same as:

cannot be accessed
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Now this fails:

account = Account()

print(account.__balance)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

So this works:

print(account._Account__balance)
Enter fullscreen mode Exit fullscreen mode

And so does this:

account._Account__balance = -1000

print(account.get_balance())
Enter fullscreen mode Exit fullscreen mode

Output:

-1000
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Now this is rejected:

account.balance = -500
Enter fullscreen mode Exit fullscreen mode

That's exactly what we want from the public API.

But someone can still deliberately bypass it:

account._Account__balance = -500
Enter fullscreen mode Exit fullscreen mode

For an ordinary object with an instance dictionary, there's another route:

account.__dict__["_Account__balance"] = -500
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Now account.__dict__ is gone.

But this still works:

account._Account__balance = -1000
Enter fullscreen mode Exit fullscreen mode

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__
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Now ordinary access is blocked:

module._secret
Enter fullscreen mode Exit fullscreen mode

Result:

AttributeError: private
Enter fullscreen mode Exit fullscreen mode

Assignment is blocked too:

module._secret = "changed"
Enter fullscreen mode Exit fullscreen mode

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"])
Enter fullscreen mode Exit fullscreen mode

Output:

top secret
Enter fullscreen mode Exit fullscreen mode

And we can modify it:

namespace["_secret"] = "changed"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Use it:

get_balance, deposit = make_account(1000)

print(get_balance())  # 1000

deposit(500)

print(get_balance())  # 1500
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Output:

1500
Enter fullscreen mode Exit fullscreen mode

Reading it is already interesting.

But modern Python lets us change the cell too:

cell.cell_contents = -500

print(get_balance())
Enter fullscreen mode Exit fullscreen mode

Output:

-500
Enter fullscreen mode Exit fullscreen mode

And the other closure continues working with the modified value:

deposit(100)

print(get_balance())
Enter fullscreen mode Exit fullscreen mode

Output:

-400
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

A hard access boundary would have said:

You cannot build against this interface.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)