TL;DR. Python's underscore prefix is documentation, not enforcement.
In small codebases that's fine. In codebases shared across teams, the
convention drifts — and reviewers spend cycles pointing it out instead
of catching real bugs. I shipped a 1.0 of strictaccess,
a small library that turns the convention into a runtime contract with
@private/@protected/@publicdecorators. It also has an explicit
"Limitations" page declaring it is not a security boundary.
The problem
You've seen this in every Python codebase past 20 contributors:
class PaymentService:
def __init__(self, gateway):
self._gateway = gateway # "protected by convention"
def _charge(self, amount): # "internal helper, don't call"
...
def process(self, order):
self._charge(order.total)
Three months later, someone writes:
# Quick fix: bypass validation, the manager said it's urgent.
service._charge(order.total + tip)
It works. The underscore is just a hint. There is no enforcement.
In code review the lead spots it and asks for a refactor. In another PR the
same pattern slips through. In a third, the _charge signature changes and
two external callers break in production. Nobody knew they existed.
This is the cost of a convention that has no teeth.
Why the obvious fixes don't fully work
1. "Just rely on __name mangling." Python mangles __x to _ClassName__x
so external code can't accidentally collide. But it's also not enforcement —
obj._ClassName__x is one line of code away. Tooling-wise, IDEs gray it out
and that's it.
2. "Write unit tests for access boundaries." Tests fail after the fact.
You wanted the call to never happen in the first place. Tests also can't
cover every call site at every time.
3. "Use a linter rule." Linters can catch direct access from outside the
class. They can't reason about runtime call chains (subclasses, callbacks,
factory functions, async handlers). False positives are common; false
negatives are worse.
4. "Use @property everywhere." Heavy. Now every field needs a getter
and a setter, and you've introduced a different problem: subclass overrides
break, and repr doesn't show internal state.
What I wanted
The semantics of Java/C++ private/protected/public, but as decorators,
preserved type information, no breaking changes to existing classes, and
honest about what it can and cannot guarantee.
strictaccess
pip install strictaccess
from strictaccess import strict_access_control, private, protected, PrivateAccessError
@strict_access_control()
class PaymentService:
def __init__(self, gateway):
self._gateway = gateway # underscore -> protected by convention
@private
def _charge(self, amount):
# ... validation, logging, atomic update ...
self._gateway.transfer(amount)
@protected
def _build_audit_record(self, amount):
return {"amount": amount, "ts": ...}
def process(self, order):
self._charge(order.total) # OK: internal call
svc = PaymentService(gw)
svc.process(order) # OK
svc._charge(order.total + tip) # raises PrivateAccessError
svc._gateway # raises ProtectedAccessError
@private allows calls from inside the defining class only. @protected
allows the class and its subclasses. Attributes whose names start with _
are protected automatically; name-mangled __x is private automatically.
The decorator preserves the decorated class's type, so mypy --strict
keeps working on consumer code:
acct: PaymentService = PaymentService(gw)
reveal_type(acct) # PaymentService, not Any
reveal_type(acct.process(o)) # the actual return type of `process`
Debug mode for legacy migrations
The hardest part of adopting any enforcement library is not the new code —
it's the existing call sites. strictaccess ships a debug mode that logs
violations as WARNING instead of raising:
@strict_access_control(debug=True)
class PaymentService:
...
# Existing callers keep working; you collect a violation log.
# Fix them gradually, then flip debug=False.
It emits via logging.getLogger("strictaccess") so you can route it to a
file, a SIEM, or just caplog in pytest.
The honest part: what it can't do
This is the section every library should have and most don't. strictaccess
is a discipline tool, not a security boundary. Specifically:
-
object.__getattribute__(obj, name)bypasses every check in one line. This is inherent to anything built on__getattribute__. If you need true isolation, you need a different process, a different language, or a different design. -
Pickling a decorated instance can fail. The wrapper class is created
dynamically via
type(...).copy.deepcopyworks;pickle.dumpsmay not. -
The runtime cost is ~20× a plain attribute access, because every
read goes through a Python-level
__getattribute__. In absolute terms that's roughly 700ns per access — fine for business logic, not fine for a 10M-iteration hot loop. -
Requires Python 3.11+ because the caller-detection engine reads
frame.f_code.co_qualname, which was introduced in CPython 3.11.
These are documented on a dedicated Limitations page,
not buried in a README footnote.
When to use it
- Codebases shared across teams where the underscore convention has drifted.
- Library boundaries — mark internals so consumers can't accidentally build on them and complain when you change them.
- Legacy refactors — turn on debug mode, collect the violation log, fix the call sites, then flip to strict.
- Teaching environments where reinforcing OOP semantics in Python helps students coming from Java/C++.
When not to use it
- Performance-critical inner loops on decorated objects.
- Code you ship to untrusted environments expecting strict isolation.
- A solo project where you trust yourself with the convention.
- Codebases stuck on Python 3.10 or older.
Try it
pip install strictaccess
- Docs: https://jhoelperaltap.github.io/strictaccess/
- Limitations: https://jhoelperaltap.github.io/strictaccess/limitations/
- Repo (issues / PRs welcome): https://github.com/Jhoelperaltap/strictaccess
Feedback I'd value most:
- Legitimate use cases I haven't documented.
- Design decisions you'd argue against (the API surface is small on purpose, but the trade-offs are still up for discussion).
- Real-world adoption stories — even "we tried it and went back to the convention because X" is gold.
If you've worked on a codebase where the underscore convention failed in a
memorable way, I want to hear that story.
Top comments (0)